Просмотр исходного кода

refactor file upload settings management

- Introduced new types for file upload settings in `FileUploadSetting.types.ts`.
- Created `useFileUploadSettings` hook to manage fetching and updating file upload settings.
- Implemented GCS settings component with updated props and logic.
- Added unit tests for file upload settings logic in `useFileUploadSettings.spec.ts`.
- Removed deprecated state management for file upload settings in `AdminAppContainer.js`.
Yuki Takei 5 месяцев назад
Родитель
Сommit
6da3183a0b

+ 7 - 19
apps/app/src/client/components/Admin/App/AwsSetting.tsx

@@ -1,23 +1,14 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 export type AwsSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  s3ReferenceFileWithRelayMode
-  s3Region
-  s3CustomEndpoint
-  s3Bucket
-  s3AccessKeyId
-  s3SecretAccessKey
+  register: UseFormRegister<FileUploadFormValues>
+  s3ReferenceFileWithRelayMode: boolean
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeS3Region: (val: string) => void
-  onChangeS3CustomEndpoint: (val: string) => void
-  onChangeS3Bucket: (val: string) => void
-  onChangeS3AccessKeyId: (val: string) => void
-  onChangeS3SecretAccessKey: (val: string) => void
 };
 
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
@@ -25,7 +16,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -48,16 +38,16 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -138,8 +128,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>
       </div>
-
-
     </>
   );
 };

+ 13 - 25
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,31 +1,21 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 
-
 export type AzureSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  azureReferenceFileWithRelayMode
-  azureUseOnlyEnvVars
-  azureTenantId
-  azureClientId
-  azureClientSecret
-  azureStorageAccountName
-  azureStorageContainerName
-  envAzureTenantId?
-  envAzureClientId?
-  envAzureClientSecret?
-  envAzureStorageAccountName?
-  envAzureStorageContainerName?
+  register: UseFormRegister<FileUploadFormValues>
+  azureReferenceFileWithRelayMode: boolean
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeAzureTenantId: (val: string) => void
-  onChangeAzureClientId: (val: string) => void
-  onChangeAzureClientSecret: (val: string) => void
-  onChangeAzureStorageAccountName: (val: string) => void
-  onChangeAzureStorageContainerName: (val: string) => void
 };
 
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
@@ -43,7 +33,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
 
   return (
     <>
-
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -66,16 +55,16 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(false) }}
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -198,7 +187,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
           </tr>
         </tbody>
       </table>
-
     </>
   );
 };

+ 112 - 282
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -1,36 +1,94 @@
-import type { ChangeEvent, JSX } from 'react';
-import React, { useCallback, useEffect } from 'react';
+import type { JSX } from 'react';
+import { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { useForm } from 'react-hook-form';
+import { useForm, useController } from 'react-hook-form';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import { AwsSettingMolecule } from './AwsSetting';
-import type { AwsSettingMoleculeProps } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
-import type { AzureSettingMoleculeProps } from './AzureSetting';
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import { GcsSettingMolecule } from './GcsSetting';
-import type { GcsSettingMoleculeProps } from './GcsSetting';
+import { useFileUploadSettings } from './useFileUploadSettings';
 
-type FileUploadSettingMoleculeProps = {
-  fileUploadType: string
-  isFixedFileUploadByEnvVar: boolean
-  envFileUploadType?: string
-  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-  register: ReturnType<typeof useForm>['register']
-} & Omit<AwsSettingMoleculeProps, 'register'> & Omit<GcsSettingMoleculeProps, 'register'> & Omit<AzureSettingMoleculeProps, 'register'>;
-
-export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
+const FileUploadSetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
+  const {
+    data, isLoading, error, updateSettings,
+  } = useFileUploadSettings();
+
+  const {
+    register, handleSubmit, control, watch, formState,
+  } = useForm<FileUploadFormValues>({
+    values: data ? {
+      fileUploadType: data.fileUploadType,
+      s3Region: data.s3Region,
+      s3CustomEndpoint: data.s3CustomEndpoint,
+      s3Bucket: data.s3Bucket,
+      s3AccessKeyId: data.s3AccessKeyId,
+      s3SecretAccessKey: data.s3SecretAccessKey,
+      s3ReferenceFileWithRelayMode: data.s3ReferenceFileWithRelayMode,
+      gcsApiKeyJsonPath: data.gcsApiKeyJsonPath,
+      gcsBucket: data.gcsBucket,
+      gcsUploadNamespace: data.gcsUploadNamespace,
+      gcsReferenceFileWithRelayMode: data.gcsReferenceFileWithRelayMode,
+      azureTenantId: data.azureTenantId,
+      azureClientId: data.azureClientId,
+      azureClientSecret: data.azureClientSecret,
+      azureStorageAccountName: data.azureStorageAccountName,
+      azureStorageContainerName: data.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: data.azureReferenceFileWithRelayMode,
+    } : undefined,
+  });
+
+  // Use controller for fileUploadType radio buttons
+  const { field: fileUploadTypeField } = useController({
+    name: 'fileUploadType',
+    control,
+  });
+
+  // Use controller for relay mode fields
+  const { field: s3RelayModeField } = useController({
+    name: 's3ReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: gcsRelayModeField } = useController({
+    name: 'gcsReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: azureRelayModeField } = useController({
+    name: 'azureReferenceFileWithRelayMode',
+    control,
+  });
+
+  const fileUploadType = watch('fileUploadType');
+
+  const onSubmit = useCallback(async(formData: FileUploadFormValues) => {
+    try {
+      await updateSettings(formData, formState.dirtyFields);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [updateSettings, formState.dirtyFields, t]);
+
+  if (isLoading) {
+    return <div>Loading...</div>;
+  }
+
+  if (error || !data) {
+    return <div>Error loading settings</div>;
+  }
 
   return (
-    <>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-warning-subtle my-3">
         {t('admin:app_setting.file_upload')}
         <span className="text-danger mt-1">
@@ -53,24 +111,27 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
                   className="form-check-input"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
-                  checked={props.fileUploadType === type}
-                  disabled={props.isFixedFileUploadByEnvVar}
-                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
+                  checked={fileUploadTypeField.value === type}
+                  disabled={data.isFixedFileUploadByEnvVar}
+                  onChange={() => fileUploadTypeField.onChange(type)}
                 />
-                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
+                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>
+                  {t(`admin:app_setting.${type}_label`)}
+                </label>
               </div>
             );
           })}
         </div>
-        {props.isFixedFileUploadByEnvVar && (
+        {data.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-start offset-3 col-6">
             <span className="material-symbols-outlined">help</span>
-            <b>FIXED</b><br />
+            <b>FIXED</b>
+            <br />
             {/* eslint-disable-next-line react/no-danger */}
             <b dangerouslySetInnerHTML={{
               __html: t('admin:app_setting.fixed_by_env_var', {
                 envKey: 'FILE_UPLOAD',
-                envVar: props.envFileUploadType,
+                envVar: data.envFileUploadType,
               }),
             }}
             />
@@ -78,274 +139,43 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         )}
       </div>
 
-      {props.fileUploadType === 'aws' && (
+      {fileUploadType === 'aws' && (
         <AwsSettingMolecule
-          register={props.register}
-          s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
-          s3Region={props.s3Region}
-          s3CustomEndpoint={props.s3CustomEndpoint}
-          s3Bucket={props.s3Bucket}
-          s3AccessKeyId={props.s3AccessKeyId}
-          s3SecretAccessKey={props.s3SecretAccessKey}
-          onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
-          onChangeS3Region={props.onChangeS3Region}
-          onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
-          onChangeS3Bucket={props.onChangeS3Bucket}
-          onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
-          onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+          register={register}
+          s3ReferenceFileWithRelayMode={s3RelayModeField.value}
+          onChangeS3ReferenceFileWithRelayMode={s3RelayModeField.onChange}
         />
       )}
-      {props.fileUploadType === 'gcs' && (
+
+      {fileUploadType === 'gcs' && (
         <GcsSettingMolecule
-          register={props.register}
-          gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
-          gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
-          gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
-          gcsBucket={props.gcsBucket}
-          gcsUploadNamespace={props.gcsUploadNamespace}
-          envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
-          envGcsBucket={props.envGcsBucket}
-          envGcsUploadNamespace={props.envGcsUploadNamespace}
-          onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
-          onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
-          onChangeGcsBucket={props.onChangeGcsBucket}
-          onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+          register={register}
+          gcsReferenceFileWithRelayMode={gcsRelayModeField.value}
+          gcsUseOnlyEnvVars={data.gcsUseOnlyEnvVars}
+          envGcsApiKeyJsonPath={data.envGcsApiKeyJsonPath}
+          envGcsBucket={data.envGcsBucket}
+          envGcsUploadNamespace={data.envGcsUploadNamespace}
+          onChangeGcsReferenceFileWithRelayMode={gcsRelayModeField.onChange}
         />
       )}
-      {props.fileUploadType === 'azure' && (
+
+      {fileUploadType === 'azure' && (
         <AzureSettingMolecule
-          register={props.register}
-          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
-          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
-          azureTenantId={props.azureTenantId}
-          azureClientId={props.azureClientId}
-          azureClientSecret={props.azureClientSecret}
-          azureStorageAccountName={props.azureStorageAccountName}
-          azureStorageContainerName={props.azureStorageContainerName}
-          envAzureStorageAccountName={props.envAzureStorageAccountName}
-          envAzureStorageContainerName={props.envAzureStorageContainerName}
-          envAzureTenantId={props.envAzureTenantId}
-          envAzureClientId={props.envAzureClientId}
-          envAzureClientSecret={props.envAzureClientSecret}
-          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
-          onChangeAzureTenantId={props.onChangeAzureTenantId}
-          onChangeAzureClientId={props.onChangeAzureClientId}
-          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
-          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
-          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+          register={register}
+          azureReferenceFileWithRelayMode={azureRelayModeField.value}
+          azureUseOnlyEnvVars={data.azureUseOnlyEnvVars}
+          envAzureTenantId={data.envAzureTenantId}
+          envAzureClientId={data.envAzureClientId}
+          envAzureClientSecret={data.envAzureClientSecret}
+          envAzureStorageAccountName={data.envAzureStorageAccountName}
+          envAzureStorageContainerName={data.envAzureStorageContainerName}
+          onChangeAzureReferenceFileWithRelayMode={azureRelayModeField.onChange}
         />
       )}
-    </>
-  );
-});
-FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
-
-
-type FileUploadSettingProps = {
-  adminAppContainer: AdminAppContainer
-}
 
-const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
-  const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-
-  const {
-    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
-    s3ReferenceFileWithRelayMode,
-    s3Region, s3CustomEndpoint, s3Bucket,
-    s3AccessKeyId, s3SecretAccessKey,
-    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
-    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
-    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
-    azureTenantId, azureClientId, azureClientSecret,
-    azureStorageAccountName, azureStorageContainerName,
-    envAzureTenantId, envAzureClientId, envAzureClientSecret,
-    envAzureStorageAccountName, envAzureStorageContainerName,
-  } = adminAppContainer.state;
-
-  const { register, handleSubmit, reset } = useForm();
-
-  // Sync form with container state
-  useEffect(() => {
-    reset({
-      s3Region,
-      s3CustomEndpoint,
-      s3Bucket,
-      s3AccessKeyId,
-      gcsApiKeyJsonPath,
-      gcsBucket,
-      gcsUploadNamespace,
-      azureTenantId,
-      azureClientId,
-      azureClientSecret,
-      azureStorageAccountName,
-      azureStorageContainerName,
-    });
-  }, [
-    reset,
-    s3Region, s3CustomEndpoint, s3Bucket, s3AccessKeyId,
-    gcsApiKeyJsonPath, gcsBucket, gcsUploadNamespace,
-    azureTenantId, azureClientId, azureClientSecret,
-    azureStorageAccountName, azureStorageContainerName,
-  ]);
-
-  const submitHandler = useCallback(async(data) => {
-    try {
-      // Update container state with form data
-      await adminAppContainer.changeS3Region(data.s3Region ?? '');
-      await adminAppContainer.changeS3CustomEndpoint(data.s3CustomEndpoint ?? '');
-      await adminAppContainer.changeS3Bucket(data.s3Bucket ?? '');
-      await adminAppContainer.changeS3AccessKeyId(data.s3AccessKeyId ?? '');
-      await adminAppContainer.changeS3SecretAccessKey(data.s3SecretAccessKey ?? '');
-      await adminAppContainer.changeGcsApiKeyJsonPath(data.gcsApiKeyJsonPath ?? '');
-      await adminAppContainer.changeGcsBucket(data.gcsBucket ?? '');
-      await adminAppContainer.changeGcsUploadNamespace(data.gcsUploadNamespace ?? '');
-      await adminAppContainer.changeAzureTenantId(data.azureTenantId ?? '');
-      await adminAppContainer.changeAzureClientId(data.azureClientId ?? '');
-      await adminAppContainer.changeAzureClientSecret(data.azureClientSecret ?? '');
-      await adminAppContainer.changeAzureStorageAccountName(data.azureStorageAccountName ?? '');
-      await adminAppContainer.changeAzureStorageContainerName(data.azureStorageContainerName ?? '');
-
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
-
-  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
-    adminAppContainer.changeFileUploadType(type);
-  }, [adminAppContainer]);
-
-  // S3
-  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3RegionHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Region(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3CustomEndpoint(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3BucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Bucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3AccessKeyId(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3SecretAccessKey(val);
-  }, [adminAppContainer]);
-
-  // GCS
-  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsApiKeyJsonPath(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsBucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsBucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsUploadNamespace(val);
-  }, [adminAppContainer]);
-
-  // Azure
-  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureTenantId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientSecret(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageAccountName(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageContainerName(val);
-  }, [adminAppContainer]);
-
-  return (
-    <form onSubmit={handleSubmit(submitHandler)}>
-      <FileUploadSettingMolecule
-        register={register}
-        fileUploadType={fileUploadType}
-        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
-        envFileUploadType={envFileUploadType}
-        onChangeFileUploadType={onChangeFileUploadTypeHandler}
-        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
-        s3Region={s3Region}
-        s3CustomEndpoint={s3CustomEndpoint}
-        s3Bucket={s3Bucket}
-        s3AccessKeyId={s3AccessKeyId}
-        s3SecretAccessKey={s3SecretAccessKey}
-        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
-        onChangeS3Region={onChangeS3RegionHandler}
-        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
-        onChangeS3Bucket={onChangeS3BucketHandler}
-        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
-        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
-        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
-        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
-        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
-        gcsBucket={gcsBucket}
-        gcsUploadNamespace={gcsUploadNamespace}
-        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
-        envGcsBucket={envGcsBucket}
-        envGcsUploadNamespace={envGcsUploadNamespace}
-        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
-        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
-        onChangeGcsBucket={onChangeGcsBucketHandler}
-        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
-        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
-        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
-        azureTenantId={azureTenantId}
-        azureClientId={azureClientId}
-        azureClientSecret={azureClientSecret}
-        azureStorageAccountName={azureStorageAccountName}
-        azureStorageContainerName={azureStorageContainerName}
-        envAzureTenantId={envAzureTenantId}
-        envAzureClientId={envAzureClientId}
-        envAzureClientSecret={envAzureClientSecret}
-        envAzureStorageAccountName={envAzureStorageAccountName}
-        envAzureStorageContainerName={envAzureStorageContainerName}
-        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
-        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
-        onChangeAzureClientId={onChangeAzureClientIdHandler}
-        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
-        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
-        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
-      />
-      <AdminUpdateButtonRow type="submit" disabled={retrieveError != null} />
+      <AdminUpdateButtonRow type="submit" disabled={isLoading} />
     </form>
   );
 };
 
-
-/**
- * Wrapper component for using unstated
- */
-const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AdminAppContainer]);
-
-export default FileUploadSettingWrapper;
+export default FileUploadSetting;

+ 41 - 0
apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts

@@ -0,0 +1,41 @@
+export type FileUploadType = 'aws' | 'gcs' | 'azure' | 'local' | 'mongodb' | 'none';
+
+export type FileUploadFormValues = {
+  fileUploadType: FileUploadType
+  // AWS S3
+  s3Region: string
+  s3CustomEndpoint: string
+  s3Bucket: string
+  s3AccessKeyId: string
+  s3SecretAccessKey: string
+  s3ReferenceFileWithRelayMode: boolean
+  // GCS
+  gcsApiKeyJsonPath: string
+  gcsBucket: string
+  gcsUploadNamespace: string
+  gcsReferenceFileWithRelayMode: boolean
+  // Azure
+  azureTenantId: string
+  azureClientId: string
+  azureClientSecret: string
+  azureStorageAccountName: string
+  azureStorageContainerName: string
+  azureReferenceFileWithRelayMode: boolean
+};
+
+export type FileUploadSettingsData = FileUploadFormValues & {
+  isFixedFileUploadByEnvVar: boolean
+  envFileUploadType?: string
+  // GCS env vars
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
+  // Azure env vars
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
+};

+ 11 - 18
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,23 +1,18 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 export type GcsSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  gcsReferenceFileWithRelayMode
-  gcsUseOnlyEnvVars
-  gcsApiKeyJsonPath
-  gcsBucket
-  gcsUploadNamespace
-  envGcsApiKeyJsonPath?
-  envGcsBucket?
-  envGcsUploadNamespace?
+  register: UseFormRegister<FileUploadFormValues>
+  gcsReferenceFileWithRelayMode: boolean
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeGcsApiKeyJsonPath: (val: string) => void
-  onChangeGcsBucket: (val: string) => void
-  onChangeGcsUploadNamespace: (val: string) => void
 };
 
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
@@ -33,7 +28,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -56,16 +50,16 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -155,7 +149,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
           </tr>
         </tbody>
       </table>
-
     </>
   );
 };

+ 3 - 2
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,7 +1,7 @@
 import type { ChangeEvent } from 'react';
 import { useState, type JSX } from 'react';
 
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
 import styles from './MaskedInput.module.scss';
 
@@ -11,7 +11,8 @@ type Props = {
   value?: string
   onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   tabIndex?: number | undefined
-  register?: UseFormRegister<FieldValues>
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register?: UseFormRegister<any>
   fieldName?: string
 };
 

+ 395 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts

@@ -0,0 +1,395 @@
+import { describe, it, expect } from 'vitest';
+
+import type { FileUploadFormValues, FileUploadSettingsData } from './FileUploadSetting.types';
+
+/**
+ * Helper function to build settings data (mimics useFileUploadSettings fetchData logic)
+ */
+function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSettingsData {
+  return {
+    // File upload type
+    fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+      ? appSettingsParams.envFileUploadType
+      : appSettingsParams.fileUploadType,
+    isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+    envFileUploadType: appSettingsParams.envFileUploadType,
+
+    // AWS S3
+    s3Region: appSettingsParams.s3Region || '',
+    s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+    s3Bucket: appSettingsParams.s3Bucket || '',
+    s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+    s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+    s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+    // GCS
+    gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+    gcsBucket: appSettingsParams.gcsBucket || '',
+    gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+    gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+    gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+    envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+    envGcsBucket: appSettingsParams.envGcsBucket,
+    envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+    // Azure
+    azureTenantId: appSettingsParams.azureTenantId || '',
+    azureClientId: appSettingsParams.azureClientId || '',
+    azureClientSecret: appSettingsParams.azureClientSecret || '',
+    azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
+    azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
+    azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+    azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
+    envAzureTenantId: appSettingsParams.envAzureTenantId,
+    envAzureClientId: appSettingsParams.envAzureClientId,
+    envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+    envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+    envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+  };
+}
+
+/**
+ * Helper function to build request params (mimics useFileUploadSettings updateSettings logic)
+ */
+function buildRequestParams(
+  formData: FileUploadFormValues,
+  dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
+): Record<string, any> {
+  const { fileUploadType } = formData;
+
+  const requestParams: Record<string, any> = {
+    fileUploadType,
+  };
+
+  if (fileUploadType === 'aws') {
+    requestParams.s3Region = formData.s3Region;
+    requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+    requestParams.s3Bucket = formData.s3Bucket;
+    requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+    // Only include secret access key if it was changed
+    if (dirtyFields.s3SecretAccessKey) {
+      requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+    }
+    requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'gcs') {
+    requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+    requestParams.gcsBucket = formData.gcsBucket;
+    requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+    requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'azure') {
+    // Only include secret fields if they were changed
+    if (dirtyFields.azureTenantId) {
+      requestParams.azureTenantId = formData.azureTenantId;
+    }
+    if (dirtyFields.azureClientId) {
+      requestParams.azureClientId = formData.azureClientId;
+    }
+    if (dirtyFields.azureClientSecret) {
+      requestParams.azureClientSecret = formData.azureClientSecret;
+    }
+    requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+    requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+    requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+  }
+
+  return requestParams;
+}
+
+describe('useFileUploadSettings - fileUploadType selection with useOnlyEnvVarForFileUploadType', () => {
+  it('should use envFileUploadType when useOnlyEnvVarForFileUploadType is true', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('aws');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is false', () => {
+    const appSettingsParams = {
+      fileUploadType: 'gcs',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: false,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is undefined', () => {
+    const appSettingsParams = {
+      fileUploadType: 'azure',
+      envFileUploadType: 'aws',
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('azure');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+  });
+
+  it('should prioritize envFileUploadType over fileUploadType when env var is enforced', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'gcs',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    // Even though DB has 'local', env var 'gcs' should be used
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+  });
+});
+
+describe('useFileUploadSettings - secret field dirty tracking', () => {
+  it('should NOT include s3SecretAccessKey in request when it is not dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '***existing-secret***', // Not changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3ReferenceFileWithRelayMode: true,
+      // s3SecretAccessKey is NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3ReferenceFileWithRelayMode: true,
+    });
+
+    // Verify s3SecretAccessKey is NOT in the request
+    expect(requestParams).not.toHaveProperty('s3SecretAccessKey');
+  });
+
+  it('should include s3SecretAccessKey in request when it is dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key', // Changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key',
+      s3ReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include empty string for s3SecretAccessKey when explicitly set to empty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '', // Explicitly cleared
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('s3SecretAccessKey', '');
+  });
+
+  it('should NOT include Azure secret fields in request when they are not dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '***existing-tenant***', // Not changed
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: '***existing-secret***', // Not changed
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+      // Azure secret fields are NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).not.toHaveProperty('azureTenantId');
+    expect(requestParams).not.toHaveProperty('azureClientId');
+    expect(requestParams).not.toHaveProperty('azureClientSecret');
+    expect(requestParams).toHaveProperty('azureStorageAccountName', 'new-account');
+    expect(requestParams).toHaveProperty('azureStorageContainerName', 'new-container');
+  });
+
+  it('should include Azure secret fields in request when they are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true,
+      azureClientId: true,
+      azureClientSecret: true,
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'azure',
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include only some Azure secret fields when only some are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true, // Marked as dirty
+      // azureClientId is NOT marked as dirty
+      azureClientSecret: true, // Marked as dirty
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
+    expect(requestParams).not.toHaveProperty('azureClientId');
+    expect(requestParams).toHaveProperty('azureClientSecret', 'new-client-secret');
+  });
+});

+ 139 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.ts

@@ -0,0 +1,139 @@
+import { useState, useEffect } from 'react';
+
+import type { FieldNamesMarkedBoolean } from 'react-hook-form';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
+import type { FileUploadSettingsData, FileUploadFormValues } from './FileUploadSetting.types';
+
+type UseFileUploadSettingsReturn = {
+  data: FileUploadSettingsData | null
+  isLoading: boolean
+  error: Error | null
+  updateSettings: (formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>) => Promise<void>
+};
+
+export function useFileUploadSettings(): UseFileUploadSettingsReturn {
+  const [data, setData] = useState<FileUploadSettingsData | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    const fetchData = async() => {
+      try {
+        setIsLoading(true);
+        const response = await apiv3Get('/app-settings/');
+        const { appSettingsParams } = response.data;
+
+        const settingsData: FileUploadSettingsData = {
+          // File upload type
+          fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+            ? appSettingsParams.envFileUploadType
+            : appSettingsParams.fileUploadType,
+          isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+          envFileUploadType: appSettingsParams.envFileUploadType,
+
+          // AWS S3
+          s3Region: appSettingsParams.s3Region || '',
+          s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+          s3Bucket: appSettingsParams.s3Bucket || '',
+          s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+          s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+          s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+          // GCS
+          gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+          gcsBucket: appSettingsParams.gcsBucket || '',
+          gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+          gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+          gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+          envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+          envGcsBucket: appSettingsParams.envGcsBucket,
+          envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+          // Azure
+          azureTenantId: appSettingsParams.azureTenantId || '',
+          azureClientId: appSettingsParams.azureClientId || '',
+          azureClientSecret: appSettingsParams.azureClientSecret || '',
+          azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
+          azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
+          azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+          azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
+          envAzureTenantId: appSettingsParams.envAzureTenantId,
+          envAzureClientId: appSettingsParams.envAzureClientId,
+          envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+          envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+          envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+        };
+
+        setData(settingsData);
+        setError(null);
+      }
+      catch (err) {
+        setError(err instanceof Error ? err : new Error('Failed to fetch settings'));
+      }
+      finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  const updateSettings = async(formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>): Promise<void> => {
+    const { fileUploadType } = formData;
+
+    const requestParams: Record<string, any> = {
+      fileUploadType,
+    };
+
+    // Add fields based on upload type
+    if (fileUploadType === 'aws') {
+      requestParams.s3Region = formData.s3Region;
+      requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+      requestParams.s3Bucket = formData.s3Bucket;
+      requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+      // Only include secret access key if it was changed
+      if (dirtyFields.s3SecretAccessKey) {
+        requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+      }
+      requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'gcs') {
+      requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+      requestParams.gcsBucket = formData.gcsBucket;
+      requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+      requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'azure') {
+      // Only include secret fields if they were changed
+      if (dirtyFields.azureTenantId) {
+        requestParams.azureTenantId = formData.azureTenantId;
+      }
+      if (dirtyFields.azureClientId) {
+        requestParams.azureClientId = formData.azureClientId;
+      }
+      if (dirtyFields.azureClientSecret) {
+        requestParams.azureClientSecret = formData.azureClientSecret;
+      }
+      requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+      requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+    }
+
+    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
+    const { responseParams } = response.data;
+
+    // Update local state with response
+    if (data) {
+      setData({
+        ...data,
+        ...responseParams,
+      });
+    }
+  };
+
+  return { data, isLoading, error, updateSettings };
+}

+ 0 - 232
apps/app/src/client/services/AdminAppContainer.js

@@ -40,39 +40,6 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: '',
       sesSecretAccessKey: '',
 
-      fileUploadType: '',
-      envFileUploadType: '',
-      isFixedFileUploadByEnvVar: false,
-
-      gcsUseOnlyEnvVars: false,
-      gcsApiKeyJsonPath: '',
-      envGcsApiKeyJsonPath: '',
-      gcsBucket: '',
-      envGcsBucket: '',
-      gcsUploadNamespace: '',
-      envGcsUploadNamespace: '',
-      gcsReferenceFileWithRelayMode: false,
-
-      s3Region: '',
-      s3CustomEndpoint: '',
-      s3Bucket: '',
-      s3AccessKeyId: '',
-      s3SecretAccessKey: '',
-      s3ReferenceFileWithRelayMode: false,
-
-      azureReferenceFileWithRelayMode: false,
-      azureUseOnlyEnvVars: false,
-      azureTenantId: '',
-      azureClientId: '',
-      azureClientSecret: '',
-      azureStorageAccountName: '',
-      azureStorageContainerName: '',
-      envAzureTenantId: '',
-      envAzureClientId: '',
-      envAzureClientSecret: '',
-      envAzureStorageAccountName: '',
-      envAzureStorageContainerName: '',
-
       isMaintenanceMode: false,
 
       // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
@@ -116,50 +83,11 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
 
-      fileUploadType: appSettingsParams.fileUploadType,
-      envFileUploadType: appSettingsParams.envFileUploadType,
-      useOnlyEnvVarForFileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType,
-
-      s3Region: appSettingsParams.s3Region,
-      s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
-      s3Bucket: appSettingsParams.s3Bucket,
-      s3AccessKeyId: appSettingsParams.s3AccessKeyId,
-      s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
-
-      gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
-      gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
-      gcsBucket: appSettingsParams.gcsBucket,
-      gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
-      gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode,
-      envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
-      envGcsBucket: appSettingsParams.envGcsBucket,
-      envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
-
-      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
-      azureTenantId: appSettingsParams.azureTenantId,
-      azureClientId: appSettingsParams.azureClientId,
-      azureClientSecret: appSettingsParams.azureClientSecret,
-      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
-      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
-      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
-      envAzureTenantId: appSettingsParams.envAzureTenantId,
-      envAzureClientId: appSettingsParams.envAzureClientId,
-      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
-      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
-      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
-
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
 
       // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
       isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
-
-    // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
-    // and if env var 'FILE_UPLOAD' is null, envFileUploadType is 'aws' that is default value of 'FILE_UPLOAD'.
-    if (appSettingsParams.useOnlyEnvVarForFileUploadType) {
-      this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
-      this.setState({ isFixedFileUploadByEnvVar: true });
-    }
   }
 
   /**
@@ -268,125 +196,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ sesSecretAccessKey });
   }
 
-  /**
-   * Change s3Region
-   */
-  changeS3Region(s3Region) {
-    this.setState({ s3Region });
-  }
-
-  /**
-   * Change s3CustomEndpoint
-   */
-  changeS3CustomEndpoint(s3CustomEndpoint) {
-    this.setState({ s3CustomEndpoint });
-  }
-
-  /**
-   * Change fileUploadType
-   */
-  changeFileUploadType(fileUploadType) {
-    this.setState({ fileUploadType });
-  }
-
-  /**
-   * Change region
-   */
-  changeS3Bucket(s3Bucket) {
-    this.setState({ s3Bucket });
-  }
-
-  /**
-   * Change access key id
-   */
-  changeS3AccessKeyId(s3AccessKeyId) {
-    this.setState({ s3AccessKeyId });
-  }
-
-  /**
-   * Change secret access key
-   */
-  changeS3SecretAccessKey(s3SecretAccessKey) {
-    this.setState({ s3SecretAccessKey });
-  }
-
-  /**
-   * Change s3ReferenceFileWithRelayMode
-   */
-  changeS3ReferenceFileWithRelayMode(s3ReferenceFileWithRelayMode) {
-    this.setState({ s3ReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change gcsApiKeyJsonPath
-   */
-  changeGcsApiKeyJsonPath(gcsApiKeyJsonPath) {
-    this.setState({ gcsApiKeyJsonPath });
-  }
-
-  /**
-   * Change gcsBucket
-   */
-  changeGcsBucket(gcsBucket) {
-    this.setState({ gcsBucket });
-  }
-
-  /**
-   * Change gcsUploadNamespace
-   */
-  changeGcsUploadNamespace(gcsUploadNamespace) {
-    this.setState({ gcsUploadNamespace });
-  }
-
-  /**
-   * Change gcsReferenceFileWithRelayMode
-   */
-  changeGcsReferenceFileWithRelayMode(gcsReferenceFileWithRelayMode) {
-    this.setState({ gcsReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change azureReferenceFileWithRelayMode
-   */
-  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
-    this.setState({ azureReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change azureTenantId
-   */
-  changeAzureTenantId(azureTenantId) {
-    this.setState({ azureTenantId });
-  }
-
-  /**
-   * Change azureClientId
-   */
-  changeAzureClientId(azureClientId) {
-    this.setState({ azureClientId });
-  }
-
-  /**
-   * Change azureClientSecret
-   */
-  changeAzureClientSecret(azureClientSecret) {
-    this.setState({ azureClientSecret });
-  }
-
-  /**
-   * Change azureStorageAccountName
-   */
-  changeAzureStorageAccountName(azureStorageAccountName) {
-    this.setState({ azureStorageAccountName });
-  }
-
-  /**
-   * Change azureStorageContainerName
-   */
-  changeAzureStorageContainerName(azureStorageContainerName) {
-    this.setState({ azureStorageContainerName });
-  }
-
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -474,47 +283,6 @@ export default class AdminAppContainer extends Container {
     return apiv3Post('/app-settings/smtp-test');
   }
 
-  /**
-   * Update updateFileUploadSettingHandler
-   * @memberOf AdminAppContainer
-   */
-  async updateFileUploadSettingHandler() {
-    const { fileUploadType } = this.state;
-
-    const requestParams = {
-      fileUploadType,
-    };
-
-    if (fileUploadType === 'gcs') {
-      requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
-      requestParams.gcsBucket = this.state.gcsBucket;
-      requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
-      requestParams.gcsReferenceFileWithRelayMode = this.state.gcsReferenceFileWithRelayMode;
-    }
-
-    if (fileUploadType === 'aws') {
-      requestParams.s3Region = this.state.s3Region;
-      requestParams.s3CustomEndpoint = this.state.s3CustomEndpoint;
-      requestParams.s3Bucket = this.state.s3Bucket;
-      requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
-      requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
-      requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
-    }
-
-    if (fileUploadType === 'azure') {
-      requestParams.azureTenantId = this.state.azureTenantId;
-      requestParams.azureClientId = this.state.azureClientId;
-      requestParams.azureClientSecret = this.state.azureClientSecret;
-      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
-      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
-      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
-    }
-
-    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
-    const { responseParams } = response.data;
-    return this.setState(responseParams);
-  }
-
   /**
    * Start v5 page migration
    * @memberOf AdminAppContainer