Yuki Takei 5 месяцев назад
Родитель
Сommit
9a61c95c23

+ 98 - 15
.serena/memories/admin-forms-migration-progress.md

@@ -36,35 +36,71 @@
    - 特記事項: 子コンポーネントとして `register` を props で受け取る
    - テスト状況: ⏳ 未テスト
 
+#### AdminCustomizeContainer 配下
+
+6. **CustomizeCssSetting.tsx** ✨ NEW
+   - パス: `apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx`
+   - 担当フィールド: カスタム CSS
+   - 特記事項: textarea での大きなテキスト入力、空値更新が重要
+   - テスト状況: ⏳ 未テスト(IME 入力、空値更新の確認が必要)
+
+7. **CustomizeScriptSetting.tsx** ✨ NEW
+   - パス: `apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx`
+   - 担当フィールド: カスタムスクリプト(JavaScript)
+   - 特記事項: Google Tag Manager の例を含む、空値更新が重要
+   - テスト状況: ⏳ 未テスト(IME 入力、空値更新の確認が必要)
+
+8. **CustomizeNoscriptSetting.tsx** ✨ NEW
+   - パス: `apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx`
+   - 担当フィールド: カスタム noscript タグ(HTML)
+   - 特記事項: Google Tag Manager の iframe 例を含む、空値更新が重要
+   - テスト状況: ⏳ 未テスト(IME 入力、空値更新の確認が必要)
+
 ### 🔄 移行対象候補(未着手)
 
-以下は AdminAppContainer または他の Admin*Container を使用している可能性があるコンポーネント:
+#### AdminCustomizeContainer 配下
+
+以下のコンポーネントは AdminCustomizeContainer を使用しているが、フォームの構造が異なる可能性があるため要調査:
 
-#### AdminAppContainer 配下(推測)
-- `apps/app/src/client/components/Admin/App/` 配下の他のコンポーネント
-  - 確認が必要
+- `CustomizeFunctionSetting.tsx` - 機能設定(複数のチェックボックス/選択肢
+- `CustomizePresentationSetting.tsx` - プレゼンテーション設定
+- その他の Customize 配下のコンポーネント
 
 #### 他の Admin Container 配下
-- AdminCustomizeContainer 配下のフォーム
+
 - AdminSecurityContainer 配下のフォーム
+  - `OidcSecuritySetting.jsx` とその Contents
+  - `SamlSecuritySetting.jsx` とその Contents
+  - `LdapSecuritySetting.jsx` とその Contents
+  - `GoogleSecuritySetting.jsx` とその Contents
+  - `GitHubSecuritySetting.jsx` とその Contents
+  - `LocalSecuritySetting.jsx` とその Contents
+- AdminMarkdownContainer 配下のフォーム
+  - `XssForm.jsx`
+  - `LineBreakForm.jsx`
+  - その他の MarkdownSetting 配下のコンポーネント
 - AdminImportContainer 配下のフォーム
 - AdminExternalAccountsContainer 配下のフォーム
 - その他の Admin*Container 配下のフォーム
 
 ### 📋 次のステップ
 
-1. **現在完了したコンポーネントの統合テスト**
-   - MailSetting (SMTP/SES) の動作確認
-   - IME 入力テスト
-   - 空値更新テスト
+1. **今回移行したコンポーネントのテスト**
+   - CustomizeCssSetting の IME 入力テスト
+   - CustomizeScriptSetting の IME 入力テスト
+   - CustomizeNoscriptSetting の IME 入力テスト
+   - 空値更新のテスト(これらのフィールドは空にできることが重要)
 
-2. **移行対象コンポーネントの洗い出し**
-   - `apps/app/src/client/components/Admin/` 配下を調査
-   - 各 Container ファイルを確認し、使用箇所を特定
+2. **CustomizeFunctionSetting の調査と移行**
+   - より複雑なフォーム構造の可能性がある
+   - チェックボックスや選択肢の扱いを確認
 
-3. **優先度の決定**
-   - よく使われるフォームから優先的に移行
-   - IME 入力が必要なフォームを優先
+3. **Security 関連フォームの優先順位決定**
+   - よく使われる認証方式から優先的に移行
+   - LDAP, OIDC, SAML などは企業での利用が多い
+
+4. **Markdown 関連フォームの調査**
+   - XssForm.jsx と LineBreakForm.jsx を確認
 
 ## 発見した問題と解決策
 
@@ -80,6 +116,53 @@
 - **原因**: `useForm({ defaultValues })` と `useEffect` での `reset()` で二重定義
 - **解決**: `defaultValues` を削除し、`reset()` のみで管理
 
+### 問題4: textarea での IME 入力問題
+- **原因**: 制御されたコンポーネント(`value` + `onChange`)を使用していた
+- **解決**: React Hook Form の `register` を使用して非制御コンポーネント化
+
+## 移行パターンの確立
+
+以下のパターンが確立されました:
+
+### 単一 textarea フィールドのパターン
+
+```typescript
+const {
+  register,
+  handleSubmit,
+  reset,
+} = useForm();
+
+useEffect(() => {
+  reset({
+    fieldName: container.state.currentFieldName || '',
+  });
+}, [container.state.currentFieldName, reset]);
+
+const onSubmit = useCallback(async(data) => {
+  try {
+    await container.changeFieldName(data.fieldName);
+    await container.updateFieldName();
+    toastSuccess('...');
+  }
+  catch (err) {
+    toastError(err);
+  }
+}, [container]);
+
+return (
+  <form onSubmit={handleSubmit(onSubmit)}>
+    <textarea {...register('fieldName')} />
+    <AdminUpdateButtonRow type="submit" />
+  </form>
+);
+```
+
+このパターンは以下に適用可能:
+- CustomizeCssSetting (CSS)
+- CustomizeScriptSetting (JavaScript)
+- CustomizeNoscriptSetting (HTML/noscript)
+
 ## 削除したファイル
 
 - ❌ `apps/app/src/client/hooks/use-text-input-with-ime.ts` - カスタムフックアプローチを廃止

+ 29 - 13
apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -18,8 +19,23 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeCss: adminCustomizeContainer.state.currentCustomizeCss || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeCss, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeCss(data.customizeCss);
       await adminCustomizeContainer.updateCustomizeCss();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_css'), ns: 'commons' }));
     }
@@ -41,17 +57,17 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control"
-              name="customizeCss"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeCss || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeCss(e.target.value) }}
-            />
-          </div>
-
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control"
+                rows={8}
+                {...register('customizeCss')}
+              />
+            </div>
+
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeNoscript: adminCustomizeContainer.state.currentCustomizeNoscript || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeNoscript, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeNoscript(data.customizeNoscript);
       await adminCustomizeContainer.updateCustomizeNoscript();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_noscript'), ns: 'commons' }));
     }
@@ -45,40 +61,40 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeNoscript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeNoscript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeNoscript(e.target.value) }}
-            />
-          </div>
-
-          <a
-            className="text-muted"
-            data-bs-toggle="collapse"
-            href="#collapseExampleHtml"
-            role="button"
-            aria-expanded="false"
-            aria-controls="collapseExampleHtml"
-          >
-            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-            Example for Google Tag Manager
-          </a>
-          <div className="collapse" id="collapseExampleHtml">
-            <PrismAsyncLight
-              style={oneDark}
-              language="javascript"
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control mb-2"
+                rows={8}
+                {...register('customizeNoscript')}
+              />
+            </div>
+
+            <a
+              className="text-muted"
+              data-bs-toggle="collapse"
+              href="#collapseExampleHtml"
+              role="button"
+              aria-expanded="false"
+              aria-controls="collapseExampleHtml"
             >
-              {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
+              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              Example for Google Tag Manager
+            </a>
+            <div className="collapse" id="collapseExampleHtml">
+              <PrismAsyncLight
+                style={oneDark}
+                language="javascript"
+              >
+                {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
   height="0"
   width="0"
   style="display:none;visibility:hidden"></iframe>`}
-            </PrismAsyncLight>
-          </div>
+              </PrismAsyncLight>
+            </div>
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeScript: adminCustomizeContainer.state.currentCustomizeScript || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeScript, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeScript(data.customizeScript);
       await adminCustomizeContainer.updateCustomizeScript();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_script'), ns: 'commons' }));
     }
@@ -42,33 +58,32 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeScript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeScript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeScript(e.target.value) }}
-            />
-          </div>
-
-          <a
-            className="text-muted"
-            data-bs-toggle="collapse"
-            href="#collapseExampleScript"
-            role="button"
-            aria-expanded="false"
-            aria-controls="collapseExampleScript"
-          >
-            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-            Example for Google Tag Manager
-          </a>
-          <div className="collapse" id="collapseExampleScript">
-            <PrismAsyncLight
-              style={oneDark}
-              language="javascript"
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control mb-2"
+                rows={8}
+                {...register('customizeScript')}
+              />
+            </div>
+
+            <a
+              className="text-muted"
+              data-bs-toggle="collapse"
+              href="#collapseExampleScript"
+              role="button"
+              aria-expanded="false"
+              aria-controls="collapseExampleScript"
             >
-              {`(function(w,d,s,l,i){
+              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              Example for Google Tag Manager
+            </a>
+            <div className="collapse" id="collapseExampleScript">
+              <PrismAsyncLight
+                style={oneDark}
+                language="javascript"
+              >
+                {`(function(w,d,s,l,i){
 w[l]=w[l]||[];
 w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
 var f=d.getElementsByTagName(s)[0],
@@ -77,10 +92,11 @@ var f=d.getElementsByTagName(s)[0],
 j.async=true;
 j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
 })(window,document,'script','dataLayer','GTM-XXXXXX');`}
-            </PrismAsyncLight>
-          </div>
+              </PrismAsyncLight>
+            </div>
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 17 - 26
apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -1,41 +1,38 @@
-import { useCallback, useRef, type JSX } from 'react';
+import { useCallback, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, UseFormSetValue } from 'react-hook-form';
 
 import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 
+type FormValues = {
+  tagWhitelist: string,
+  attrWhitelist: string,
+}
+
 type Props ={
-  adminMarkDownContainer: AdminMarkDownContainer
+  adminMarkDownContainer: AdminMarkDownContainer,
+  register: UseFormRegister<FormValues>,
+  setValue: UseFormSetValue<FormValues>,
 }
 
 export const WhitelistInput = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('admin');
-  const { adminMarkDownContainer } = props;
-
-  const tagNamesRef = useRef<HTMLTextAreaElement>(null);
-  const attrsRef = useRef<HTMLTextAreaElement>(null);
+  const { adminMarkDownContainer, register, setValue } = props;
 
   const clickRecommendTagButtonHandler = useCallback(() => {
-    if (tagNamesRef.current == null) {
-      return;
-    }
-
     const tagWhitelist = recommendedTagNames.join(',');
-    tagNamesRef.current.value = tagWhitelist;
+    setValue('tagWhitelist', tagWhitelist);
     adminMarkDownContainer.setState({ tagWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
   const clickRecommendAttrButtonHandler = useCallback(() => {
-    if (attrsRef.current == null) {
-      return;
-    }
-
     const attrWhitelist = JSON.stringify(recommendedAttributes);
-    attrsRef.current.value = attrWhitelist;
+    setValue('attrWhitelist', attrWhitelist);
     adminMarkDownContainer.setState({ attrWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
   return (
     <>
@@ -47,13 +44,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
         </div>
         <textarea
-          ref={tagNamesRef}
           className="form-control xss-list"
-          name="recommendedTags"
           rows={6}
           cols={40}
-          value={adminMarkDownContainer.state.tagWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
+          {...register('tagWhitelist')}
         />
       </div>
       <div className="mt-4">
@@ -64,13 +58,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
         </div>
         <textarea
-          ref={attrsRef}
           className="form-control xss-list"
-          name="recommendedAttrs"
           rows={6}
           cols={40}
-          value={adminMarkDownContainer.state.attrWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
+          {...register('attrWhitelist')}
         />
       </div>
     </>