From 1138f6dcceab6d113253d89e659e64fe468ffadb Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Tue, 6 May 2025 14:44:44 +0200
Subject: [PATCH] refactor: job create modal (#18106)

* refactor: job create modal

* chore: better modal manager types (#18107)
---
 web/src/lib/managers/modal-manager.svelte.ts  | 11 ++-
 web/src/lib/modals/JobCreateModal.svelte      | 65 +++++++++++++++++
 web/src/routes/admin/jobs-status/+page.svelte | 73 +++----------------
 .../routes/admin/user-management/+page.svelte |  2 +-
 4 files changed, 83 insertions(+), 68 deletions(-)
 create mode 100644 web/src/lib/modals/JobCreateModal.svelte

diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts
index c8cefe8a58..73a3351a7e 100644
--- a/web/src/lib/managers/modal-manager.svelte.ts
+++ b/web/src/lib/managers/modal-manager.svelte.ts
@@ -2,11 +2,14 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dial
 import { mount, unmount, type Component, type ComponentProps } from 'svelte';
 
 type OnCloseData<T> = T extends { onClose: (data: infer R) => void } ? R : never;
-// TODO make `props` optional if component only has `onClose`
-// type OptionalIfEmpty<T extends object> = keyof T extends never ? undefined : T;
+type OptionalIfEmpty<T extends object> = keyof T extends never ? undefined : T;
 
 class ModalManager {
-  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>) {
+  open<T extends object, K = OnCloseData<T>>(
+    Component: Component<T>,
+    props?: OptionalIfEmpty<Omit<T, 'onClose'>> | Record<string, never>,
+  ): Promise<K>;
+  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: OptionalIfEmpty<Omit<T, 'onClose'>>) {
     return new Promise<K>((resolve) => {
       let modal: object = {};
 
@@ -18,7 +21,7 @@ class ModalManager {
       modal = mount(Component, {
         target: document.body,
         props: {
-          ...(props as T),
+          ...((props ?? {}) as T),
           onClose,
         },
       });
diff --git a/web/src/lib/modals/JobCreateModal.svelte b/web/src/lib/modals/JobCreateModal.svelte
new file mode 100644
index 0000000000..6c173f918c
--- /dev/null
+++ b/web/src/lib/modals/JobCreateModal.svelte
@@ -0,0 +1,65 @@
+<script lang="ts">
+  import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
+  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
+  import { createJob, ManualJobName } from '@immich/sdk';
+  import { t } from 'svelte-i18n';
+
+  type Props = { onClose: (confirmed: boolean) => void };
+
+  let { onClose }: Props = $props();
+
+  const options = [
+    { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
+    { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
+    { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
+    { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
+    { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
+    { title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
+  ].map(({ value, title }) => ({ id: value, label: title, value }));
+
+  let selectedJob: ComboBoxOption | undefined = $state(undefined);
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleCreate();
+  };
+
+  const handleCreate = async () => {
+    if (!selectedJob) {
+      return;
+    }
+
+    try {
+      await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } });
+      notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info });
+      onClose(true);
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_submit_job'));
+    }
+  };
+</script>
+
+<ConfirmDialog
+  confirmColor="primary"
+  title={$t('admin.create_job')}
+  disabled={!selectedJob}
+  onClose={(confirmed) => (confirmed ? handleCreate() : onClose(false))}
+>
+  {#snippet promptSnippet()}
+    <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
+      <div class="flex flex-col gap-1 text-start">
+        <Combobox
+          bind:selectedOption={selectedJob}
+          label={$t('jobs')}
+          {options}
+          placeholder={$t('admin.search_jobs')}
+        />
+      </div>
+    </form>
+  {/snippet}
+</ConfirmDialog>
diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte
index af7f3d11af..c5327a4271 100644
--- a/web/src/routes/admin/jobs-status/+page.svelte
+++ b/web/src/routes/admin/jobs-status/+page.svelte
@@ -1,16 +1,11 @@
 <script lang="ts">
   import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
-  import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
-  import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
-  import {
-    notificationController,
-    NotificationType,
-  } from '$lib/components/shared-components/notification/notification';
   import { AppRoute } from '$lib/constants';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
   import { asyncTimeout } from '$lib/utils';
-  import { handleError } from '$lib/utils/handle-error';
-  import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk';
+  import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk';
   import { Button, HStack, Text } from '@immich/ui';
   import { mdiCog, mdiPlus } from '@mdi/js';
   import { onDestroy, onMount } from 'svelte';
@@ -26,8 +21,6 @@
   let jobs: AllJobStatusResponseDto | undefined = $state();
 
   let running = true;
-  let isOpen = $state(false);
-  let selectedJob: ComboBoxOption | undefined = $state(undefined);
 
   onMount(async () => {
     while (running) {
@@ -39,42 +32,18 @@
   onDestroy(() => {
     running = false;
   });
-
-  const options = [
-    { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
-    { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
-    { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
-    { title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
-    { title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
-    { title: $t('admin.backup_database'), value: ManualJobName.BackupDatabase },
-  ].map(({ value, title }) => ({ id: value, label: title, value }));
-
-  const handleCancel = () => (isOpen = false);
-
-  const handleCreate = async () => {
-    if (!selectedJob) {
-      return;
-    }
-
-    try {
-      await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } });
-      notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info });
-      handleCancel();
-    } catch (error) {
-      handleError(error, $t('errors.unable_to_submit_job'));
-    }
-  };
-
-  const onsubmit = async (event: Event) => {
-    event.preventDefault();
-    await handleCreate();
-  };
 </script>
 
 <UserPageLayout title={data.meta.title} admin>
   {#snippet buttons()}
     <HStack gap={0}>
-      <Button leadingIcon={mdiPlus} onclick={() => (isOpen = true)} size="small" variant="ghost" color="secondary">
+      <Button
+        leadingIcon={mdiPlus}
+        onclick={() => modalManager.open(JobCreateModal)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+      >
         <Text class="hidden md:block">{$t('admin.create_job')}</Text>
       </Button>
       <Button
@@ -96,25 +65,3 @@
     </section>
   </section>
 </UserPageLayout>
-
-{#if isOpen}
-  <ConfirmDialog
-    confirmColor="primary"
-    title={$t('admin.create_job')}
-    disabled={!selectedJob}
-    onClose={(confirmed) => (confirmed ? handleCreate() : handleCancel())}
-  >
-    {#snippet promptSnippet()}
-      <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
-        <div class="flex flex-col gap-1 text-start">
-          <Combobox
-            bind:selectedOption={selectedJob}
-            label={$t('jobs')}
-            {options}
-            placeholder={$t('admin.search_jobs')}
-          />
-        </div>
-      </form>
-    {/snippet}
-  </ConfirmDialog>
-{/if}
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index f6e7025ce2..8ac3793046 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -59,7 +59,7 @@
   };
 
   const handleCreate = async () => {
-    await modalManager.open(UserCreateModal, {});
+    await modalManager.open(UserCreateModal);
     await refresh();
   };