diff --git a/i18n/en.json b/i18n/en.json index ff4689f426..e3f144356e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -14,9 +14,11 @@ "add_a_location": "Add a location", "add_a_name": "Add a name", "add_a_title": "Add a title", + "add_action": "Add action", "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", + "add_filter": "Add filter", "add_location": "Add location", "add_more_users": "Add more users", "add_partner": "Add partner", @@ -454,6 +456,7 @@ "album_remove_user": "Remove user?", "album_remove_user_confirmation": "Are you sure you want to remove {user}?", "album_search_not_found": "No albums found matching your search", + "album_selected": "Album selected", "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.", "album_summary": "Album summary", "album_updated": "Album updated", @@ -475,6 +478,7 @@ "albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "albums_feature_description": "Collections of assets that can be shared with other users.", "albums_on_device_count": "Albums on device ({count})", + "albums_selected": "{count, plural, one {# album selected} other {# albums selected}}", "all": "All", "all_albums": "All albums", "all_people": "All people", @@ -511,10 +515,12 @@ "archived_count": "{count, plural, other {Archived #}}", "are_these_the_same_person": "Are these the same person?", "are_you_sure_to_do_this": "Are you sure you want to do this?", + "array_field_not_fully_supported": "Array fields require manual JSON editing", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_added_to_album": "Added to album", "asset_adding_to_album": "Adding to album…", + "asset_created": "Asset created", "asset_description_updated": "Asset description has been updated", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", @@ -697,6 +703,8 @@ "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", "change_pin_code": "Change PIN code", + "change_trigger": "Change trigger", + "change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.", "change_your_password": "Change your password", "changed_visibility_successfully": "Changed visibility successfully", "charging": "Charging", @@ -771,6 +779,7 @@ "create_album": "Create album", "create_album_page_untitled": "Untitled", "create_api_key": "Create API key", + "create_first_workflow": "Create first workflow", "create_library": "Create Library", "create_link": "Create link", "create_link_to_share": "Create link to share", @@ -785,6 +794,7 @@ "create_tag": "Create tag", "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", + "create_workflow": "Create workflow", "created": "Created", "created_at": "Created", "creating_linked_albums": "Creating linked albums...", @@ -913,16 +923,19 @@ "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", + "edit_workflow": "Edit workflow", "editor": "Editor", "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_crop_tool_h2_rotation": "Rotation", + "editor_mode": "Editor mode", "email": "Email", "email_notifications": "Email notifications", "empty_folder": "This folder is empty", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", + "disable": "Disable", "enable": "Enable", "enable_backup": "Enable Backup", "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", @@ -1156,6 +1169,7 @@ "hi_user": "Hi {name} ({email})", "hide_all_people": "Hide all people", "hide_gallery": "Hide gallery", + "hide_json": "Hide JSON", "hide_named_person": "Hide person {name}", "hide_password": "Hide password", "hide_person": "Hide person", @@ -1231,6 +1245,8 @@ "ios_debug_info_processing_ran_at": "Processing ran {dateTime}", "items_count": "{count, plural, one {# item} other {# items}}", "jobs": "Jobs", + "json_editor": "JSON editor", + "json_error": "JSON error", "keep": "Keep", "keep_all": "Keep All", "keep_this_delete_others": "Keep this, delete others", @@ -1399,11 +1415,13 @@ "monthly_title_text_date_format": "MMMM y", "more": "More", "move": "Move", + "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", "move_to": "Move to", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", + "move_up": "Move up", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_trash": "Moved to trash", @@ -1413,6 +1431,7 @@ "my_albums": "My albums", "name": "Name", "name_or_nickname": "Name or nickname", + "name_required": "Name is required", "navigate": "Navigate", "navigate_to_time": "Navigate to Time", "network_requirement_photos_upload": "Use cellular data to backup photos", @@ -1437,6 +1456,7 @@ "next": "Next", "next_memory": "Next memory", "no": "No", + "no_actions_added": "No actions added yet", "no_albums_message": "Create an album to organize your photos and videos", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "no_albums_yet": "It looks like you do not have any albums yet.", @@ -1446,11 +1466,13 @@ "no_cast_devices_found": "No cast devices found", "no_checksum_local": "No checksum available - cannot fetch local assets", "no_checksum_remote": "No checksum available - cannot fetch remote asset", + "no_configuration_needed": "No configuration needed", "no_devices": "No authorized devices", "no_duplicates_found": "No duplicates were found.", "no_exif_info_available": "No exif info available", "no_explore_results_message": "Upload more photos to explore your collection.", "no_favorites_message": "Add favorites to quickly find your best pictures and videos", + "no_filters_added": "No filters added yet", "no_libraries_message": "Create an external library to view your photos and videos", "no_local_assets_found": "No local assets found with this checksum", "no_location_set": "No location set", @@ -1464,6 +1486,7 @@ "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_uploads_in_progress": "No uploads in progress", + "no_workflows_yet": "No workflows yet", "not_allowed": "Not allowed", "not_available": "N/A", "not_in_any_album": "Not in any album", @@ -1545,6 +1568,7 @@ "people": "People", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", "people_feature_description": "Browsing photos and videos grouped by people", + "people_selected": "{count, plural, one {# person selected} other {# people selected}}", "people_sidebar_description": "Display a link to People in the sidebar", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", @@ -1569,6 +1593,8 @@ "person_age_years": "{years, plural, other {# years}} old", "person_birthdate": "Born on {date}", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "person_recognized": "Person recognized", + "person_selected": "Person selected", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photos": "Photos", "photos_and_videos": "Photos & Videos", @@ -1821,23 +1847,19 @@ "select_album": "Select album", "select_album_cover": "Select album cover", "select_albums": "Select albums", - "album_selected": "Album selected", - "albums_selected": "{count, plural, one {# album selected} other {# albums selected}}", - "select_person": "Select person", - "select_people": "Select people", - "person_selected": "Person selected", - "people_selected": "{count, plural, one {# person selected} other {# people selected}}", - "select_count": "{count, plural, one {Select #} other {Select #}}", "select_all": "Select all", "select_all_duplicates": "Select all duplicates", "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", + "select_count": "{count, plural, one {Select #} other {Select #}}", "select_face": "Select face", "select_featured_photo": "Select featured photo", "select_from_computer": "Select from computer", "select_keep_all": "Select keep all", "select_library_owner": "Select library owner", "select_new_face": "Select new face", + "select_people": "Select people", + "select_person": "Select person", "select_person_to_tag": "Select a person to tag", "select_photos": "Select photos", "select_trash_all": "Select trash all", @@ -1967,6 +1989,7 @@ "show_hidden_people": "Show hidden people", "show_in_timeline": "Show in timeline", "show_in_timeline_setting_description": "Show photos and videos from this user in your timeline", + "show_json": "Show JSON", "show_keyboard_shortcuts": "Show keyboard shortcuts", "show_metadata": "Show metadata", "show_or_hide_info": "Show or hide info", @@ -2099,6 +2122,8 @@ "trash_page_select_assets_btn": "Select assets", "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", + "trigger": "Trigger", + "trigger_type": "Trigger type", "troubleshoot": "Troubleshoot", "type": "Type", "unable_to_change_pin_code": "Unable to change PIN code", @@ -2129,7 +2154,9 @@ "unstack": "Un-stack", "unstack_action_prompt": "{count} unstacked", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", + "unsupported_field_type": "Unsupported field type", "untagged": "Untagged", + "untitled_workflow": "Untitled workflow", "up_next": "Up next", "update_location_action_prompt": "Update the location of {count} selected assets with:", "updated_at": "Updated", @@ -2175,6 +2202,7 @@ "utilities": "Utilities", "validate": "Validate", "validate_endpoint_error": "Please enter a valid URL", + "validation_error": "Validation error", "variables": "Variables", "version": "Version", "version_announcement_closing": "Your friend, Alex", @@ -2205,6 +2233,7 @@ "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", + "visual_builder": "Visual builder", "waiting": "Waiting", "warning": "Warning", "week": "Week", @@ -2215,6 +2244,7 @@ "workflow_deleted": "Workflow deleted", "workflow_json": "Workflow JSON", "workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.", + "workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?", "workflow_updated": "Workflow updated", "workflows_help_text": "Workflows automate actions on your assets based on triggers and filters.", "wrong_pin_code": "Wrong PIN code", @@ -2224,30 +2254,5 @@ "you_dont_have_any_shared_links": "You don't have any shared links", "your_wifi_name": "Your Wi-Fi name", "zoom_image": "Zoom Image", - "zoom_to_bounds": "Zoom to bounds", - "add_action": "Add action", - "add_filter": "Add filter", - "array_field_not_fully_supported": "Array fields require manual JSON editing", - "asset_created": "Asset created", - "create_first_workflow": "Create first workflow", - "create_workflow": "Create workflow", - "edit_workflow": "Edit workflow", - "editor_mode": "Editor mode", - "hide_json": "Hide JSON", - "json_editor": "JSON editor", - "json_error": "JSON error", - "move_down": "Move down", - "move_up": "Move up", - "name_required": "Name is required", - "no_actions_added": "No actions added yet", - "no_configuration_needed": "No configuration needed", - "no_filters_added": "No filters added yet", - "no_workflows_yet": "No workflows yet", - "person_recognized": "Person recognized", - "show_json": "Show JSON", - "trigger_type": "Trigger type", - "unsupported_field_type": "Unsupported field type", - "untitled_workflow": "Untitled workflow", - "validation_error": "Validation error", - "visual_builder": "Visual builder" + "zoom_to_bounds": "Zoom to bounds" } diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart index 028149475c..135c032b77 100644 --- a/mobile/openapi/lib/model/workflow_update_dto.dart +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -18,7 +18,7 @@ class WorkflowUpdateDto { this.enabled, this.filters = const [], this.name, - required this.triggerType, + this.triggerType, }); List actions; @@ -49,7 +49,13 @@ class WorkflowUpdateDto { /// String? name; - PluginTriggerType triggerType; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + PluginTriggerType? triggerType; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto && @@ -68,7 +74,7 @@ class WorkflowUpdateDto { (enabled == null ? 0 : enabled!.hashCode) + (filters.hashCode) + (name == null ? 0 : name!.hashCode) + - (triggerType.hashCode); + (triggerType == null ? 0 : triggerType!.hashCode); @override String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; @@ -92,7 +98,11 @@ class WorkflowUpdateDto { } else { // json[r'name'] = null; } + if (this.triggerType != null) { json[r'triggerType'] = this.triggerType; + } else { + // json[r'triggerType'] = null; + } return json; } @@ -110,7 +120,7 @@ class WorkflowUpdateDto { enabled: mapValueOfType(json, r'enabled'), filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), name: mapValueOfType(json, r'name'), - triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + triggerType: PluginTriggerType.fromJson(json[r'triggerType']), ); } return null; @@ -158,7 +168,6 @@ class WorkflowUpdateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'triggerType', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3c33871bab..75f5ffb200 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22985,9 +22985,6 @@ ] } }, - "required": [ - "triggerType" - ], "type": "object" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 30a7848ba0..73d41ee760 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1763,7 +1763,7 @@ export type WorkflowUpdateDto = { enabled?: boolean; filters?: WorkflowFilterItemDto[]; name?: string; - triggerType: PluginTriggerType; + triggerType?: PluginTriggerType; }; /** * List all activities diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index 4cc0a0e594..056bbcac6f 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -48,8 +48,8 @@ export class WorkflowCreateDto { } export class WorkflowUpdateDto { - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) - triggerType!: PluginTriggerType; + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true }) + triggerType?: PluginTriggerType; @IsString() @IsNotEmpty() diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index afc51dcca3..2253487955 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -56,7 +56,7 @@ export class WorkflowService extends BaseService { } const workflow = await this.findOrFail(id); - const trigger = this.getTriggerOrFail(workflow.triggerType); + const trigger = this.getTriggerOrFail(dto.triggerType ?? workflow.triggerType); const { filters, actions, ...workflowUpdate } = dto; const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context)); diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index aaf1c8b9ec..ddbaf46592 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -611,6 +611,52 @@ describe(WorkflowService.name, () => { sut.update(auth, created.id, { actions: [{ actionId: factory.uuid(), actionConfig: {} }] }), ).rejects.toThrow(); }); + + it('should update trigger type', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.PersonRecognized, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + triggerType: PluginTriggerType.AssetCreate, + }); + + expect(updated.triggerType).toBe(PluginTriggerType.AssetCreate); + }); + + it('should use existing trigger type when triggerType not provided in update', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [ + { filterId: testFilterId, filterConfig: { updated: true } }, + { filterId: testFilterId, filterConfig: { second: true } }, + ], + }); + + expect(updated.triggerType).toBe(PluginTriggerType.AssetCreate); + expect(updated.filters).toHaveLength(2); + }); }); describe('delete', () => { diff --git a/web/src/lib/modals/WorkflowEditorModal.svelte b/web/src/lib/modals/WorkflowEditorModal.svelte deleted file mode 100644 index 4e9584794b..0000000000 --- a/web/src/lib/modals/WorkflowEditorModal.svelte +++ /dev/null @@ -1,217 +0,0 @@ - - - - -
- handleModeChange(mode as 'visual' | 'json')} - /> -
- - {#if editorMode === 'visual'} -
{ - e.preventDefault(); - void handleSubmit(); - }} - class="mt-4 flex flex-col gap-4" - > - - - - - - - -

- {$t('workflow_json_help')} -

- - {/if} - - - - - - - - - diff --git a/web/src/lib/modals/WorkflowNavigationConfirmModal.svelte b/web/src/lib/modals/WorkflowNavigationConfirmModal.svelte new file mode 100644 index 0000000000..8e8be53d8e --- /dev/null +++ b/web/src/lib/modals/WorkflowNavigationConfirmModal.svelte @@ -0,0 +1,16 @@ + + + (confirmed ? onClose(true) : onClose(false))} +/> diff --git a/web/src/lib/modals/WorkflowTriggerUpdateConfirmModal.svelte b/web/src/lib/modals/WorkflowTriggerUpdateConfirmModal.svelte new file mode 100644 index 0000000000..f15bd25126 --- /dev/null +++ b/web/src/lib/modals/WorkflowTriggerUpdateConfirmModal.svelte @@ -0,0 +1,19 @@ + + + (confirmed ? onClose(true) : onClose(false))} +/> diff --git a/web/src/routes/(user)/utilities/workflows/+page.svelte b/web/src/routes/(user)/utilities/workflows/+page.svelte index 0aa8c9094f..2e4c89373c 100644 --- a/web/src/routes/(user)/utilities/workflows/+page.svelte +++ b/web/src/routes/(user)/utilities/workflows/+page.svelte @@ -2,28 +2,37 @@ import { goto } from '$app/navigation'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import { AppRoute } from '$lib/constants'; - import { copyToClipboard } from '$lib/utils'; + import type { WorkflowPayload } from '$lib/services/workflow.service'; import { handleError } from '$lib/utils/handle-error'; import { createWorkflow, deleteWorkflow, PluginTriggerType, updateWorkflow, + type PluginActionResponseDto, + type PluginFilterResponseDto, + type PluginResponseDto, type WorkflowResponseDto, } from '@immich/sdk'; - import { Button, Card, CardBody, CardTitle, HStack, Icon, IconButton, toastManager } from '@immich/ui'; import { - mdiChevronDown, - mdiChevronUp, - mdiContentCopy, - mdiDelete, - mdiPencil, - mdiPlay, - mdiPlayPause, - mdiPlus, - } from '@mdi/js'; + Button, + Card, + CardBody, + CardDescription, + CardHeader, + CardTitle, + CodeBlock, + HStack, + Icon, + IconButton, + MenuItemType, + menuManager, + Text, + toastManager, + } from '@immich/ui'; + import { mdiCodeJson, mdiDelete, mdiDotsVertical, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; - import { SvelteSet } from 'svelte/reactivity'; + import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import type { PageData } from './$types'; interface Props { @@ -33,24 +42,57 @@ let { data }: Props = $props(); let workflows = $state(data.workflows); - // svelte-ignore non_reactive_update - let expandedWorkflows = new SvelteSet(); + const expandedWorkflows = new SvelteSet(); + + const pluginFilterLookup = new SvelteMap(); + const pluginActionLookup = new SvelteMap(); + + for (const plugin of data.plugins as PluginResponseDto[]) { + for (const filter of plugin.filters ?? []) { + pluginFilterLookup.set(filter.id, { ...filter, pluginTitle: plugin.title }); + } + + for (const action of plugin.actions ?? []) { + pluginActionLookup.set(action.id, { ...action, pluginTitle: plugin.title }); + } + } const toggleExpanded = (id: string) => { - const newExpanded = new SvelteSet(expandedWorkflows); - if (newExpanded.has(id)) { - newExpanded.delete(id); + if (expandedWorkflows.has(id)) { + expandedWorkflows.delete(id); } else { - newExpanded.add(id); + expandedWorkflows.add(id); } - expandedWorkflows = newExpanded; }; - const handleCopyWorkflow = async (workflow: WorkflowResponseDto) => { - const workflowJson = JSON.stringify(workflow, null, 2); - await copyToClipboard(workflowJson); + const buildShareableWorkflow = (workflow: WorkflowResponseDto): WorkflowPayload => { + const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order); + const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order); + + return { + name: workflow.name ?? '', + description: workflow.description ?? '', + enabled: workflow.enabled, + triggerType: workflow.triggerType, + filters: orderedFilters.map((wfFilter) => { + const meta = pluginFilterLookup.get(wfFilter.filterId); + const key = meta?.methodName ?? wfFilter.filterId; + return { + [key]: wfFilter.filterConfig ?? {}, + }; + }), + actions: orderedActions.map((wfAction) => { + const meta = pluginActionLookup.get(wfAction.actionId); + const key = meta?.methodName ?? wfAction.actionId; + return { + [key]: wfAction.actionConfig ?? {}, + }; + }), + }; }; + const getWorkflowJson = (workflow: WorkflowResponseDto) => JSON.stringify(buildShareableWorkflow(workflow), null, 2); + const handleToggleEnabled = async (workflow: WorkflowResponseDto) => { try { const updated = await updateWorkflow({ @@ -93,6 +135,38 @@ await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`); }; + type WorkflowChip = { + id: string; + title: string; + subtitle: string; + }; + + const getFilterChips = (workflow: WorkflowResponseDto): WorkflowChip[] => { + return [...(workflow.filters ?? [])] + .sort((a, b) => a.order - b.order) + .map((filter) => { + const meta = pluginFilterLookup.get(filter.filterId); + return { + id: filter.id, + title: meta?.title ?? $t('filter'), + subtitle: meta?.pluginTitle ?? $t('workflow'), + }; + }); + }; + + const getActionChips = (workflow: WorkflowResponseDto): WorkflowChip[] => { + return [...(workflow.actions ?? [])] + .sort((a, b) => a.order - b.order) + .map((action) => { + const meta = pluginActionLookup.get(action.actionId); + return { + id: action.id, + title: meta?.title ?? $t('action'), + subtitle: meta?.pluginTitle ?? $t('workflow'), + }; + }); + }; + const getTriggerLabel = (triggerType: string) => { const labels: Record = { AssetCreate: $t('asset_created'), @@ -100,12 +174,39 @@ }; return labels[triggerType] || triggerType; }; + + const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + const formatTimestamp = (iso?: string) => { + if (!iso) { + return '—'; + } + return dateFormatter.format(new Date(iso)); + }; + + type WorkflowWithMeta = { + workflow: WorkflowResponseDto; + filterChips: WorkflowChip[]; + actionChips: WorkflowChip[]; + workflowJson: string; + }; + + const getWorkflowsWithMeta = (): WorkflowWithMeta[] => + workflows.map((workflow) => ({ + workflow, + filterChips: getFilterChips(workflow), + actionChips: getActionChips(workflow), + workflowJson: getWorkflowJson(workflow), + })); {#snippet buttons()} - @@ -127,95 +228,165 @@ {:else} -
- {#each workflows as workflow (workflow.id)} - - -
-
-
- {workflow.name || $t('untitled_workflow')} - {#if workflow.description} -

{workflow.description}

+
+ {#each getWorkflowsWithMeta() as { workflow, filterChips, actionChips, workflowJson } (workflow.id)} + + +
+
+ + {#if workflow.enabled} + {/if} -
- - handleToggleEnabled(workflow)} - class={workflow.enabled ? 'text-green-500' : 'text-gray-400'} - /> - handleCopyWorkflow(workflow)} - /> - handleEditWorkflow(workflow)} - /> - handleDeleteWorkflow(workflow)} - /> - -
- -
- - {workflow.enabled ? $t('enabled') : $t('disabled')} + - + {workflow.name} +
+ + {workflow.description || $t('workflows_help_text')} + +
+ +
+
+ {$t('created_at')} + + {formatTimestamp(workflow.createdAt)} + +
+ { + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-left', + items: [ + { + title: workflow.enabled ? $t('disable') : $t('enable'), + color: workflow.enabled ? 'warning' : 'success', + icon: workflow.enabled ? mdiPause : mdiPlay, + onSelect: () => void handleToggleEnabled(workflow), + }, + { + title: $t('edit'), + icon: mdiPencil, + onSelect: () => void handleEditWorkflow(workflow), + }, + + { + title: expandedWorkflows.has(workflow.id) ? $t('hide_json') : $t('show_json'), + icon: mdiCodeJson, + onSelect: () => toggleExpanded(workflow.id), + }, + MenuItemType.Divider, + { + title: $t('delete'), + icon: mdiDelete, + color: 'danger', + onSelect: () => void handleDeleteWorkflow(workflow), + }, + ], + }); + }} + /> +
+ + + +
+ +
+
+ {$t('trigger')} +
+ {getTriggerLabel(workflow.triggerType)} - - {workflow.filters.length} - {workflow.filters.length === 1 ? $t('filter') : $t('filter')} - - - {workflow.actions.length} - {workflow.actions.length === 1 ? $t('action') : $t('actions')} -
- - - {#if expandedWorkflows.has(workflow.id)} -
-
{JSON.stringify(
-                          workflow,
-                          null,
-                          2,
-                        )}
+
+
+ {$t('filter')} +
+
+ {workflow.filters.length} +
- {/if} +
+ {#if filterChips.length === 0} + + {$t('no_filters_added')} + + {:else} + {#each filterChips as chip (chip.id)} + + {chip.title} + + {/each} + {/if} +
+
+ + +
+
+
+ {$t('actions')} +
+
+ {workflow.actions.length} +
+
+
+ {#if actionChips.length === 0} + + {$t('no_actions_added')} + + {:else} + {#each actionChips as chip (chip.id)} + + {chip.title} + + {/each} + {/if} +
+
+ + {#if expandedWorkflows.has(workflow.id)} +
+

Workflow JSON

+ +
+ {/if}
{/each} diff --git a/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte b/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte index d8806dd9e5..7d623d6ec4 100644 --- a/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte +++ b/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte @@ -1,4 +1,5 @@ {#snippet cardOrder(index: number)} @@ -387,7 +433,7 @@ (selectedTrigger = trigger)} + onclick={() => handleTriggerChange(trigger)} /> {/each}