diff --git a/.github/mergify.yml b/.github/mergify.yml index d4a54c44980..c259d183f1b 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -1,6 +1,5 @@ queue_rules: - name: default - allow_inplace_checks: false queue_conditions: - check-success ~= ci-app-lint - check-success ~= ci-app-test diff --git a/.serena/memories/apps-app-google-workspace-oauth2-mail.md b/.serena/memories/apps-app-google-workspace-oauth2-mail.md new file mode 100644 index 00000000000..97b83c8b8b4 --- /dev/null +++ b/.serena/memories/apps-app-google-workspace-oauth2-mail.md @@ -0,0 +1,37 @@ +# Google Workspace OAuth 2.0 メール送信機能実装計画 + +## 概要 + +Google Workspace (Gmail) の OAuth 2.0 (XOAUTH2) 認証を使ったメール送信機能を実装する。2025年5月1日以降、Gmail SMTP ではユーザー名とパスワード認証がサポートされなくなったため、OAuth 2.0 への移行が必要。 + +## 背景 + +- **問題**: Gmail SMTP でのユーザー名・パスワード認証が2025年5月1日にサポート終了 +- **解決策**: OAuth 2.0 (XOAUTH2) 認証方式の実装 +- **参考**: https://support.google.com/a/answer/2956491?hl=ja +- **ライブラリ**: nodemailer v6.9.15 は OAuth 2.0 をサポート済み(バージョンアップ不要) + +## 技術仕様 + +### 必須設定パラメータ + +| パラメータ | 説明 | セキュリティ | +|-----------|------|------------| +| `mail:oauth2ClientId` | Google Cloud Console で取得する OAuth 2.0 クライアント ID | 通常 | +| `mail:oauth2ClientSecret` | OAuth 2.0 クライアントシークレット | `isSecret: true` | +| `mail:oauth2RefreshToken` | OAuth 2.0 リフレッシュトークン | `isSecret: true` | +| `mail:oauth2User` | 送信者のGmailアドレス | 通常 | + +### nodemailer 設定例 + +```typescript +const transportOptions = { + service: 'gmail', + auth: { + type: 'OAuth2', + user: 'user@example.com', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + refreshToken: 'REFRESH_TOKEN', + }, +}; \ No newline at end of file diff --git a/.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md b/.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md new file mode 100644 index 00000000000..4e0cca75d0f --- /dev/null +++ b/.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md @@ -0,0 +1,105 @@ +# PagePathNav と SubNavigation の z-index レイヤリング + +## 概要 + +PagePathNav(ページパス表示)と GrowiContextualSubNavigation(PageControls等を含むサブナビゲーション)の +Sticky 状態における z-index の重なり順を修正した際の知見。 + +## 修正したバグ + +### 症状 +スクロールしていって PagePathNav がウィンドウ上端に近づいたときに、PageControls のボタンが +PagePathNav の要素の裏側に回ってしまい、クリックできなくなる。 + +### 原因 +z-index 的に以下のように重なっていたため: + +**[Before]** 下層から順に: +1. PageView の children - z-0 +2. ( GroundGlassBar = PageControls ) ← 同じ層 z-1 +3. PagePathNav + +PageControls が PagePathNav より下層にいたため、sticky 境界付近でクリック不能になっていた。 + +## 修正後の構成 + +**[After]** 下層から順に: +1. PageView の children - z-0 +2. GroundGlassBar(磨りガラス背景)- z-1 +3. PagePathNav - z-2(通常時)/ z-3(sticky時) +4. PageControls(nav要素)- z-3 + +### ファイル構成 + +- `GrowiContextualSubNavigation.tsx` - GroundGlassBar を分離してレンダリング + - 1つ目: GroundGlassBar のみ(`position-fixed`, `z-1`) + - 2つ目: nav 要素(`z-3`) +- `PagePathNavSticky.tsx` - z-index を動的に切り替え + - 通常時: `z-2` + - sticky時: `z-3` + +## 実装のポイント + +### GroundGlassBar を分離した理由 +GroundGlassBar を `position-fixed` で常に固定表示にすることで、 +PageControls と切り離して独立した z-index 層として扱えるようにした。 + +これにより、GroundGlassBar → PagePathNav → PageControls という +理想的なレイヤー構造を実現できた。 + +## CopyDropdown が z-2 で動作しない理由(解決済み) + +### 問題 + +`PagePathNavSticky.tsx` の sticky 時の z-index について: + +```tsx +// これだと CopyDropdown(マウスオーバーで表示されるドロップダウン)が出ない +innerActiveClass="active z-2 mt-1" + +// これだと正常に動作する +innerActiveClass="active z-3 mt-1" +``` + +### 原因 + +1. `GrowiContextualSubNavigation` の sticky-inner-wrapper は `z-3` かつ横幅いっぱい(Flex アイテム) +2. この要素が PagePathNavSticky(`z-2`)の上に重なる +3. CopyDropdown は `.grw-page-path-nav-layout:hover` で `visibility: visible` になる仕組み + (参照: `PagePathNavLayout.module.scss`) +4. **z-3 の要素が上に被さっているため、hover イベントが PagePathNavSticky に届かない** +5. 結果、CopyDropdown のアイコンが表示されない + +### なぜ z-3 で動作するか + +- 同じ z-index: 3 になるため、DOM 順序で前後が決まる +- PagePathNavSticky は GrowiContextualSubNavigation より後にレンダリングされるため前面に来る +- hover イベントが正常に届き、CopyDropdown が表示される + +### 結論 + +PagePathNavSticky の sticky 時の z-index は `z-3` である必要がある。 +これは GrowiContextualSubNavigation と同じ層に置くことで、DOM 順序による前後関係を利用するため。 + +## 関連ファイル + +- `apps/app/src/client/components/PageView/PageView.tsx` +- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx` +- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss` +- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx` +- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss` +- `apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx`(CopyDropdown を含む) + +## ライブラリの注意事項 + +### react-stickynode の deprecation +`react-stickynode` は **2025-12-31 で deprecated** となる予定。 +https://github.com/yahoo/react-stickynode + +将来的には CSS `position: sticky` + `IntersectionObserver` への移行を検討する必要がある。 + +## 注意事項 + +- z-index の値を変更する際は、上記のレイヤー構造を壊さないよう注意 +- Sticky コンポーネントの `innerActiveClass` で z-index を指定する際、 + 他のコンポーネントとの相互作用を確認すること diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js index 420453b1765..7dbb8ac1b7d 100644 --- a/apps/app/.eslintrc.js +++ b/apps/app/.eslintrc.js @@ -37,10 +37,73 @@ module.exports = { 'src/interfaces/**', 'src/utils/**', 'src/components/**', + 'src/client/components/DescendantsPageListModal/**', + 'src/client/components/ItemsTree/**', + 'src/client/components/LoginForm/**', + 'src/client/components/Page/**', + 'src/client/components/PageAttachment/**', + 'src/client/components/PageDeleteModal/**', + 'src/client/components/PageDuplicateModal/**', + 'src/client/components/PageList/**', + 'src/client/components/PageManagement/**', + 'src/client/components/PagePathNavSticky/**', + 'src/client/components/PagePresentationModal/**', + 'src/client/components/PageRenameModal/**', + 'src/client/components/PageSelectModal/**', + 'src/client/components/PageSideContents/**', 'src/client/components/*.tsx', 'src/client/components/*.jsx', 'src/client/components/*.ts', 'src/client/components/*.js', + 'src/client/components/Admin/*.ts', + 'src/client/components/Admin/*.tsx', + 'src/client/components/Admin/*.scss', + 'src/client/components/Admin/AdminHome/**', + 'src/client/components/Admin/Common/**', + 'src/client/components/Admin/ElasticsearchManagement/**', + 'src/client/components/Admin/ExportArchiveData/**', + 'src/client/components/Admin/ImportData/**', + 'src/client/components/Admin/LegacySlackIntegration/**', + 'src/client/components/Admin/MarkdownSetting/**', + 'src/client/components/Admin/App/**', + 'src/client/components/Admin/AuditLog/**', + 'src/client/components/Admin/Customize/**', + 'src/client/components/Admin/Notification/**', + 'src/client/components/Admin/Security/**', + 'src/client/components/Admin/SlackIntegration/**', + 'src/client/components/Admin/Users/**', + 'src/client/components/Admin/UserGroup/**', + 'src/client/components/Admin/UserGroupDetail/**', + 'src/client/components/Me/**', + 'src/client/components/Bookmarks/**', + 'src/client/components/InAppNotification/**', + 'src/client/components/PageTags/**', + 'src/client/components/ReactMarkdownComponents/**', + 'src/client/components/AuthorInfo/**', + 'src/client/components/Common/**', + 'src/client/components/CreateTemplateModal/**', + 'src/client/components/CustomNavigation/**', + 'src/client/components/DeleteBookmarkFolderModal/**', + 'src/client/components/EmptyTrashModal/**', + 'src/client/components/GrantedGroupsInheritanceSelectModal/**', + 'src/client/components/Icons/**', + 'src/client/components/Maintenance/**', + 'src/client/components/PageControls/**', + 'src/client/components/PageComment/**', + 'src/client/components/PageAccessoriesModal/**', + 'src/client/components/PageHistory/**', + 'src/client/components/Presentation/**', + 'src/client/components/PutbackPageModal/**', + 'src/client/components/RecentActivity/**', + 'src/client/components/RecentCreated/**', + 'src/client/components/RevisionComparer/**', + 'src/client/components/ShortcutsModal/**', + 'src/client/components/StaffCredit/**', + 'src/client/components/TemplateModal/**', + 'src/client/components/PageEditor/**', + 'src/client/components/Hotkeys/**', + 'src/client/components/Navbar/**', + 'src/client/components/PageHeader/**', 'src/client/components/Sidebar/**', 'src/services/**', 'src/states/**', diff --git a/apps/app/package.json b/apps/app/package.json index 64c25210ad7..c64f1f0a9b5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,6 +1,6 @@ { "name": "@growi/app", - "version": "7.4.1", + "version": "7.4.2-RC.0", "license": "MIT", "private": "true", "scripts": { @@ -173,7 +173,7 @@ "multer": "~1.4.0", "multer-autoreap": "^1.0.3", "mustache": "^4.2.0", - "next": "^14.2.32", + "next": "^14.2.35", "next-dynamic-loading-props": "^0.1.1", "next-i18next": "^15.3.1", "next-superjson": "^1.0.7", @@ -193,7 +193,7 @@ "passport-saml": "^3.2.0", "pathe": "^2.0.3", "prop-types": "^15.8.1", - "qs": "^6.11.1", + "qs": "^6.14.1", "rate-limiter-flexible": "^2.3.7", "react": "^18.2.0", "react-bootstrap-typeahead": "^6.3.2", diff --git a/apps/app/playwright/utils/Login.ts b/apps/app/playwright/utils/Login.ts index 525d0be7a53..bf30d8bc7ff 100644 --- a/apps/app/playwright/utils/Login.ts +++ b/apps/app/playwright/utils/Login.ts @@ -7,12 +7,15 @@ export const login = async (page: Page): Promise => { // Perform authentication steps. Replace these actions with your own. await page.goto('/admin'); - const loginForm = await page.getByRole('form'); + const loginForm = await page.getByTestId('login-form'); if (loginForm != null) { - await page.getByLabel('Username or E-mail').fill('admin'); - await page.getByLabel('Password').fill('adminadmin'); - await page.locator('[type=submit]').filter({ hasText: 'Login' }).click(); + await loginForm.getByPlaceholder('Username or E-mail').fill('admin'); + await loginForm.getByPlaceholder('Password').fill('adminadmin'); + await loginForm + .locator('[type=submit]') + .filter({ hasText: 'Login' }) + .click(); } await page.waitForURL('/admin'); diff --git a/apps/app/public/static/locales/en_US/admin.json b/apps/app/public/static/locales/en_US/admin.json index b3aeb38276d..8413e511587 100644 --- a/apps/app/public/static/locales/en_US/admin.json +++ b/apps/app/public/static/locales/en_US/admin.json @@ -731,7 +731,7 @@ "description1": "Temporarily issue new users by email addresses.", "description2": "A temporary password will be generated for the first login.", "invite_thru_email": "Send invitation email", - "mail_setting_link": "settingsEmail settings", + "mail_setting_link": "settingsEmail settings", "valid_email": "Valid email address is required", "temporary_password": "The created user has a temporary password", "send_new_password": "Please send the new password to the user.", diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json index 64dcaa9db8c..a64c7561b33 100644 --- a/apps/app/public/static/locales/en_US/translation.json +++ b/apps/app/public/static/locales/en_US/translation.json @@ -786,6 +786,11 @@ "updatedAt": "Last update date" } }, + "help_dropdown": { + "show_shortcuts": "Show shortcuts", + "growi_cloud_help": "GROWI.cloud Help", + "growi_version": "GROWI version" + }, "private_legacy_pages": { "title": "Private Legacy Pages", "bulk_operation": "Bulk operation", diff --git a/apps/app/public/static/locales/fr_FR/admin.json b/apps/app/public/static/locales/fr_FR/admin.json index 106d5a72353..1b6aa5c3d5e 100644 --- a/apps/app/public/static/locales/fr_FR/admin.json +++ b/apps/app/public/static/locales/fr_FR/admin.json @@ -731,7 +731,7 @@ "description1": "Créer des utilisateurs temporaires avec une adresse courriel.", "description2": "Un mot de passe temporaire est généré automatiquement.", "invite_thru_email": "Courriel d'invitation", - "mail_setting_link": "settingsParamètres courriel", + "mail_setting_link": "settingsParamètres courriel", "valid_email": "Adresse courriel valide requise", "temporary_password": "Cette utilisateur a un mot de passe temporaire", "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.", diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json index 3a6f2792703..6313bcebda0 100644 --- a/apps/app/public/static/locales/fr_FR/translation.json +++ b/apps/app/public/static/locales/fr_FR/translation.json @@ -780,6 +780,11 @@ "updatedAt": "Dernière modification" } }, + "help_dropdown": { + "show_shortcuts": "Afficher les raccourcis", + "growi_cloud_help": "Aide GROWI.cloud", + "growi_version": "Version GROWI" + }, "private_legacy_pages": { "title": "Anciennes pages privées", "bulk_operation": "Opération de masse", diff --git a/apps/app/public/static/locales/ja_JP/admin.json b/apps/app/public/static/locales/ja_JP/admin.json index 8f94b7fd1b7..18b9595d271 100644 --- a/apps/app/public/static/locales/ja_JP/admin.json +++ b/apps/app/public/static/locales/ja_JP/admin.json @@ -740,7 +740,7 @@ "description1": "メールアドレスを使用して新規ユーザーを仮発行します。", "description2": "初回のログイン時に使用する仮パスワードが生成されます。", "invite_thru_email": "招待メールを送信する", - "mail_setting_link": "settingsメールの設定", + "mail_setting_link": "settingsメールの設定", "valid_email": "メールアドレスを入力してください。", "temporary_password": "作成したユーザーは仮パスワードが設定されています。", "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。", diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json index 3afef6febc5..d214e7c9eae 100644 --- a/apps/app/public/static/locales/ja_JP/translation.json +++ b/apps/app/public/static/locales/ja_JP/translation.json @@ -819,6 +819,11 @@ "updatedAt": "更新日時" } }, + "help_dropdown": { + "show_shortcuts": "ショートカットを表示", + "growi_cloud_help": "GROWI.cloud ヘルプ", + "growi_version": "GROWI バージョン" + }, "private_legacy_pages": { "title": "旧形式のプライベートページ", "bulk_operation": "一括操作", diff --git a/apps/app/public/static/locales/ko_KR/admin.json b/apps/app/public/static/locales/ko_KR/admin.json index cd34092d0a7..a742f4d139f 100644 --- a/apps/app/public/static/locales/ko_KR/admin.json +++ b/apps/app/public/static/locales/ko_KR/admin.json @@ -731,7 +731,7 @@ "description1": "이메일 주소로 새 사용자를 임시 발급합니다.", "description2": "첫 로그인 시 임시 비밀번호가 생성됩니다.", "invite_thru_email": "초대 이메일 전송", - "mail_setting_link": "settings이메일 설정", + "mail_setting_link": "settings이메일 설정", "valid_email": "유효한 이메일 주소가 필요합니다.", "temporary_password": "생성된 사용자에게는 임시 비밀번호가 있습니다.", "send_new_password": "새 비밀번호를 사용자에게 보내주십시오.", diff --git a/apps/app/public/static/locales/ko_KR/translation.json b/apps/app/public/static/locales/ko_KR/translation.json index 6951b1f0b41..619aac42b99 100644 --- a/apps/app/public/static/locales/ko_KR/translation.json +++ b/apps/app/public/static/locales/ko_KR/translation.json @@ -746,6 +746,11 @@ "updatedAt": "마지막 업데이트일" } }, + "help_dropdown": { + "show_shortcuts": "단축키 표시", + "growi_cloud_help": "GROWI.cloud 도움말", + "growi_version": "GROWI 버전" + }, "private_legacy_pages": { "title": "비공개 레거시 페이지", "bulk_operation": "대량 작업", diff --git a/apps/app/public/static/locales/zh_CN/admin.json b/apps/app/public/static/locales/zh_CN/admin.json index d6b2c29ef78..2a270109eec 100644 --- a/apps/app/public/static/locales/zh_CN/admin.json +++ b/apps/app/public/static/locales/zh_CN/admin.json @@ -739,7 +739,7 @@ "emails": "电子邮件", "description1": "通过电子邮件地址临时发布新用户。", "description2": "将为首次登录生成一个临时密码。", - "mail_setting_link": "settingsEmail settings", + "mail_setting_link": "settingsEmail settings", "valid_email": "需要有效的电子邮件地址", "invite_thru_email": "发送邀请电子邮件", "temporary_password": "创建的用户具有临时密码", diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json index fa2d3ef01ca..6e481c19623 100644 --- a/apps/app/public/static/locales/zh_CN/translation.json +++ b/apps/app/public/static/locales/zh_CN/translation.json @@ -791,6 +791,11 @@ "updatedAt": "按更新日期排序" } }, + "help_dropdown": { + "show_shortcuts": "显示快捷键", + "growi_cloud_help": "GROWI.cloud 帮助", + "growi_version": "GROWI 版本" + }, "private_legacy_pages": { "title": "私人遗留页面", "bulk_operation": "批量操作", diff --git a/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx b/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx index fc43abd8c32..09f2be3e647 100644 --- a/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx +++ b/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx @@ -1,5 +1,4 @@ -import React, { useEffect, useCallback } from 'react'; - +import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'next-i18next'; import PropTypes from 'prop-types'; import { CopyToClipboard } from 'react-copy-to-clipboard'; @@ -10,14 +9,10 @@ import { toastError } from '~/client/util/toastr'; import { useSWRxV5MigrationStatus } from '~/stores/page-listing'; import loggerFactory from '~/utils/logger'; - import { withUnstatedContainers } from '../../UnstatedUtils'; - - import { EnvVarsTable } from './EnvVarsTable'; import SystemInfomationTable from './SystemInfomationTable'; - const logger = loggerFactory('growi:admin'); const AdminHome = (props) => { @@ -25,11 +20,10 @@ const AdminHome = (props) => { const { t } = useTranslation(); const { data: migrationStatus } = useSWRxV5MigrationStatus(); - const fetchAdminHomeData = useCallback(async() => { + const fetchAdminHomeData = useCallback(async () => { try { await adminHomeContainer.retrieveAdminHomeData(); - } - catch (err) { + } catch (err) { toastError(err); logger.error(err); } @@ -48,25 +42,36 @@ const AdminHome = (props) => {

{t('admin:maintenance_mode.maintenance_mode')}

-

- {t('admin:maintenance_mode.description')} -

+

{t('admin:maintenance_mode.description')}


- - {t('admin:maintenance_mode.end_maintenance_mode')} + + + {t('admin:maintenance_mode.end_maintenance_mode')} + ) } { // Alert message will be displayed in case that V5 migration has not been compleated - (migrationStatus != null && !migrationStatus.isV5Compatible) - && ( -
+ migrationStatus != null && !migrationStatus.isV5Compatible && ( +
{t('admin:v5_page_migration.migration_desc')} - + {t('admin:v5_page_migration.upgrade_to_v5')}
@@ -80,43 +85,65 @@ const AdminHome = (props) => {
-

{t('admin:admin_top.system_information')}

+

+ {t('admin:admin_top.system_information')} +

-

{t('admin:admin_top.list_of_env_vars')}

+

+ {t('admin:admin_top.list_of_env_vars')} +

{t('admin:admin_top.env_var_priority')}

- {/* eslint-disable-next-line react/no-danger */} -

+

-

{t('admin:admin_top.bug_report')}

+

+ {t('admin:admin_top.bug_report')} +

adminHomeContainer.onCopyPrefilledHostInformation()} > - {t('admin:admin_top:copy_prefilled_host_information:done')} - {/* eslint-disable-next-line react/no-danger */} - +
@@ -124,8 +151,9 @@ const AdminHome = (props) => { ); }; - -const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AdminHomeContainer]); +const AdminHomeWrapper = withUnstatedContainers(AdminHome, [ + AdminHomeContainer, +]); AdminHome.propTypes = { adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired, diff --git a/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx b/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx index ccb5d46cbcb..334a969a369 100644 --- a/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx +++ b/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx @@ -1,12 +1,14 @@ -import React, { type JSX } from 'react'; - +import type React from 'react'; +import type { JSX } from 'react'; import { LoadingSpinner } from '@growi/ui/dist/components'; type EnvVarsTableProps = { - envVars?: Record, -} + envVars?: Record; +}; -export const EnvVarsTable: React.FC = (props: EnvVarsTableProps) => { +export const EnvVarsTable: React.FC = ( + props: EnvVarsTableProps, +) => { const { envVars } = props; if (envVars == null) { return ; @@ -27,9 +29,7 @@ export const EnvVarsTable: React.FC = (props: EnvVarsTablePro return ( - - {envVarRows} - + {envVarRows}
); }; diff --git a/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx b/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx index c1ce1241987..6011d7fc8e1 100644 --- a/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx +++ b/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx @@ -1,55 +1,62 @@ import React from 'react'; - import { LoadingSpinner } from '@growi/ui/dist/components'; import AdminHomeContainer from '~/client/services/AdminHomeContainer'; import { withUnstatedContainers } from '../../UnstatedUtils'; - type Props = { - adminHomeContainer: AdminHomeContainer, -} + adminHomeContainer: AdminHomeContainer; +}; const SystemInformationTable = (props: Props) => { const { adminHomeContainer } = props; - const { - growiVersion, nodeVersion, npmVersion, pnpmVersion, - } = adminHomeContainer.state; + const { growiVersion, nodeVersion, npmVersion, pnpmVersion } = + adminHomeContainer.state; - if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) { + if ( + growiVersion == null || + nodeVersion == null || + npmVersion == null || + pnpmVersion == null + ) { return ; } return ( - +
- + - + - + - +
GROWI{ growiVersion }{growiVersion}
node.js{ nodeVersion }{nodeVersion}
npm{ npmVersion }{npmVersion}
pnpm{ pnpmVersion }{pnpmVersion}
); - }; /** * Wrapper component for using unstated */ -const SystemInformationTableWrapper = withUnstatedContainers(SystemInformationTable, [AdminHomeContainer]); +const SystemInformationTableWrapper = withUnstatedContainers( + SystemInformationTable, + [AdminHomeContainer], +); export default SystemInformationTableWrapper; diff --git a/apps/app/src/client/components/Admin/App/AppSetting.jsx b/apps/app/src/client/components/Admin/App/AppSetting.jsx index e53f4172324..df909d82e9f 100644 --- a/apps/app/src/client/components/Admin/App/AppSetting.jsx +++ b/apps/app/src/client/components/Admin/App/AppSetting.jsx @@ -1,31 +1,24 @@ import React, { useCallback, useEffect } from 'react'; - -import { useTranslation, i18n } from 'next-i18next'; +import { i18n, useTranslation } from 'next-i18next'; import PropTypes from 'prop-types'; import { useForm } from 'react-hook-form'; import { i18n as i18nConfig } from '^/config/next-i18next.config'; import AdminAppContainer from '~/client/services/AdminAppContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import loggerFactory from '~/utils/logger'; - import { withUnstatedContainers } from '../../UnstatedUtils'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; const logger = loggerFactory('growi:appSettings'); - const AppSetting = (props) => { const { adminAppContainer } = props; const { t } = useTranslation(['admin', 'commons']); - const { - register, - handleSubmit, - reset, - } = useForm(); + const { register, handleSubmit, reset } = useForm(); // Reset form when adminAppContainer state changes (e.g., after reload) useEffect(() => { @@ -34,8 +27,11 @@ const AppSetting = (props) => { confidential: adminAppContainer.state.confidential || '', globalLang: adminAppContainer.state.globalLang || 'en-US', // Convert boolean to string for radio button value - isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true), - isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false, + isEmailPublishedForNewUser: String( + adminAppContainer.state.isEmailPublishedForNewUser ?? true, + ), + isReadOnlyForNewUser: + adminAppContainer.state.isReadOnlyForNewUser ?? false, }); }, [ adminAppContainer.state.title, @@ -46,47 +42,67 @@ const AppSetting = (props) => { reset, ]); - const onSubmit = useCallback(async(data) => { - try { - // Await all setState completions before API call - await Promise.all([ - adminAppContainer.changeTitle(data.title), - adminAppContainer.changeConfidential(data.confidential), - adminAppContainer.changeGlobalLang(data.globalLang), - ]); - // Convert string 'true'/'false' to boolean - const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true; - await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished); - await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser); - - await adminAppContainer.updateAppSettingHandler(); - toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') })); - } - catch (err) { - toastError(err); - logger.error(err); - } - }, [adminAppContainer, t]); - + const onSubmit = useCallback( + async (data) => { + try { + // Await all setState completions before API call + await Promise.all([ + adminAppContainer.changeTitle(data.title), + adminAppContainer.changeConfidential(data.confidential), + adminAppContainer.changeGlobalLang(data.globalLang), + ]); + // Convert string 'true'/'false' to boolean + const isEmailPublished = + data.isEmailPublishedForNewUser === 'true' || + data.isEmailPublishedForNewUser === true; + await adminAppContainer.changeIsEmailPublishedForNewUserShow( + isEmailPublished, + ); + await adminAppContainer.changeIsReadOnlyForNewUserShow( + data.isReadOnlyForNewUser, + ); + + await adminAppContainer.updateAppSettingHandler(); + toastSuccess( + t('commons:toaster.update_successed', { + target: t('commons:headers.app_settings'), + }), + ); + } catch (err) { + toastError(err); + logger.error(err); + } + }, + [adminAppContainer, t], + ); return (
- +
-

{t('admin:app_setting.sitename_change')}

+

+ {t('admin:app_setting.sitename_change')} +

@@ -95,49 +111,52 @@ const AppSetting = (props) => { className="form-control" type="text" placeholder={t('admin:app_setting.confidential_example')} + id="admin-app-setting-confidential-name" {...register('confidential')} /> -

{t('admin:app_setting.header_content')}

+

+ {t('admin:app_setting.header_content')} +

- +
- { - i18nConfig.locales.map((locale) => { - if (i18n == null) { return } - const fixedT = i18n.getFixedT(locale, 'admin'); - - return ( -
- - -
- ); - }) - } + {i18nConfig.locales.map((locale) => { + if (i18n == null) { + return null; + } + const fixedT = i18n.getFixedT(locale, 'admin'); + + return ( +
+ + +
+ ); + })}
- +
-
{ value="true" {...register('isEmailPublishedForNewUser')} /> - +
@@ -157,20 +181,24 @@ const AppSetting = (props) => { value="false" {...register('isEmailPublishedForNewUser')} /> - +
-
-
{ className="form-check-input" {...register('isReadOnlyForNewUser')} /> - +
- + ); - }; - /** * Wrapper component for using unstated */ -const AppSettingWrapper = withUnstatedContainers(AppSetting, [AdminAppContainer]); +const AppSettingWrapper = withUnstatedContainers(AppSetting, [ + AdminAppContainer, +]); AppSetting.propTypes = { adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired, }; - export default AppSettingWrapper; diff --git a/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx b/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx index 0d49a23bbc0..c3539e1dabe 100644 --- a/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx +++ b/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx @@ -1,5 +1,4 @@ import React, { useEffect } from 'react'; - import { useTranslation } from 'next-i18next'; import AdminAppContainer from '~/client/services/AdminAppContainer'; @@ -9,7 +8,6 @@ import { toArrayIfNot } from '~/utils/array-utils'; import loggerFactory from '~/utils/logger'; import { withUnstatedContainers } from '../../UnstatedUtils'; - import AppSetting from './AppSetting'; import FileUploadSetting from './FileUploadSetting'; import MailSetting from './MailSetting'; @@ -18,12 +16,11 @@ import PageBulkExportSettings from './PageBulkExportSettings'; import SiteUrlSetting from './SiteUrlSetting'; import V5PageMigration from './V5PageMigration'; - const logger = loggerFactory('growi:appSettings'); type Props = { - adminAppContainer: AdminAppContainer, -} + adminAppContainer: AdminAppContainer; +}; const AppSettingsPageContents = (props: Props) => { const { t } = useTranslation('admin'); @@ -34,14 +31,13 @@ const AppSettingsPageContents = (props: Props) => { const { isV5Compatible } = adminAppContainer.state; useEffect(() => { - const fetchAppSettingsData = async() => { + const fetchAppSettingsData = async () => { await adminAppContainer.retrieveAppSettingsData(); }; try { fetchAppSettingsData(); - } - catch (err) { + } catch (err) { const errs = toArrayIfNot(err); toastError(errs); logger.error(errs); @@ -57,67 +53,90 @@ const AppSettingsPageContents = (props: Props) => {

{t('admin:maintenance_mode.maintenance_mode')}

-

- {t('admin:maintenance_mode.description')} -

+

{t('admin:maintenance_mode.description')}


- - - {t('admin:maintenance_mode.end_maintenance_mode')} + + + + {t('admin:maintenance_mode.end_maintenance_mode')} + ) } - { - !isV5Compatible - && ( -
-
-

{t('V5 Page Migration')}

- -
-
- ) - } + {!isV5Compatible && ( +
+
+

+ {t('V5 Page Migration')} +

+ +
+
+ )}
-

{t('headers.app_settings', { ns: 'commons' })}

+

+ {t('headers.app_settings', { ns: 'commons' })} +

-

{t('app_setting.site_url.title')}

+

+ {t('app_setting.site_url.title')} +

-

{t('app_setting.mail_settings')}

+

+ {t('app_setting.mail_settings')} +

-

{t('admin:app_setting.file_upload_settings')}

+

+ {t('admin:app_setting.file_upload_settings')} +

-

{t('admin:app_setting.page_bulk_export_settings')}

+

+ {t('admin:app_setting.page_bulk_export_settings')} +

-

{t('admin:maintenance_mode.maintenance_mode')}

+

+ {t('admin:maintenance_mode.maintenance_mode')} +

@@ -128,6 +147,9 @@ const AppSettingsPageContents = (props: Props) => { /** * Wrapper component for using unstated */ -const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]); +const AppSettingsPageContentsWrapper = withUnstatedContainers( + AppSettingsPageContents, + [AdminAppContainer], +); export default AppSettingsPageContentsWrapper; diff --git a/apps/app/src/client/components/Admin/App/AwsSetting.tsx b/apps/app/src/client/components/Admin/App/AwsSetting.tsx index 0aa191fcac0..b693491bb14 100644 --- a/apps/app/src/client/components/Admin/App/AwsSetting.tsx +++ b/apps/app/src/client/components/Admin/App/AwsSetting.tsx @@ -1,25 +1,26 @@ import type { JSX } from 'react'; - import { useTranslation } from 'next-i18next'; import type { UseFormRegister } from 'react-hook-form'; import type { FileUploadFormValues } from './FileUploadSetting.types'; export type AwsSettingMoleculeProps = { - register: UseFormRegister - s3ReferenceFileWithRelayMode: boolean - onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void + register: UseFormRegister; + s3ReferenceFileWithRelayMode: boolean; + onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void; }; -export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => { +export const AwsSettingMolecule = ( + props: AwsSettingMoleculeProps, +): JSX.Element => { const { t } = useTranslation(); return ( <>
- +
@@ -31,21 +32,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element aria-haspopup="true" aria-expanded="true" > - {props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')} - {!props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')} + {props.s3ReferenceFileWithRelayMode && + t('admin:app_setting.file_delivery_method_relay')} + {!props.s3ReferenceFileWithRelayMode && + t('admin:app_setting.file_delivery_method_redirect')} -
+
@@ -61,20 +68,27 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
-
-
-
-
-
diff --git a/apps/app/src/client/components/Admin/App/AzureSetting.tsx b/apps/app/src/client/components/Admin/App/AzureSetting.tsx index 75f90602c9b..eaa84e2a3f0 100644 --- a/apps/app/src/client/components/Admin/App/AzureSetting.tsx +++ b/apps/app/src/client/components/Admin/App/AzureSetting.tsx @@ -1,5 +1,4 @@ import type { JSX } from 'react'; - import { useTranslation } from 'next-i18next'; import type { UseFormRegister } from 'react-hook-form'; @@ -7,18 +6,20 @@ import type { FileUploadFormValues } from './FileUploadSetting.types'; import MaskedInput from './MaskedInput'; export type AzureSettingMoleculeProps = { - register: UseFormRegister - azureReferenceFileWithRelayMode: boolean - azureUseOnlyEnvVars: boolean - envAzureTenantId?: string - envAzureClientId?: string - envAzureClientSecret?: string - envAzureStorageAccountName?: string - envAzureStorageContainerName?: string - onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void + register: UseFormRegister; + azureReferenceFileWithRelayMode: boolean; + azureUseOnlyEnvVars: boolean; + envAzureTenantId?: string; + envAzureClientId?: string; + envAzureClientSecret?: string; + envAzureStorageAccountName?: string; + envAzureStorageContainerName?: string; + onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void; }; -export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => { +export const AzureSettingMolecule = ( + props: AzureSettingMoleculeProps, +): JSX.Element => { const { t } = useTranslation(); const { @@ -34,9 +35,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem return ( <>
- +
@@ -48,21 +49,27 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem aria-haspopup="true" aria-expanded="true" > - {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')} - {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')} + {azureReferenceFileWithRelayMode && + t('admin:app_setting.file_delivery_method_relay')} + {!azureReferenceFileWithRelayMode && + t('admin:app_setting.file_delivery_method_redirect')} -
+
@@ -81,10 +88,17 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem

and from i18n strings + dangerouslySetInnerHTML={{ + __html: t('admin:app_setting.azure_note_for_the_only_env_option', { + env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS', + }), + }} /> )} - +
@@ -108,10 +122,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem /> @@ -125,10 +152,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem /> @@ -142,10 +182,23 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem /> @@ -160,10 +213,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem /> @@ -178,10 +245,24 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem /> diff --git a/apps/app/src/client/components/Admin/App/ConfirmModal.tsx b/apps/app/src/client/components/Admin/App/ConfirmModal.tsx index e015cddd92e..0ca31c71b40 100644 --- a/apps/app/src/client/components/Admin/App/ConfirmModal.tsx +++ b/apps/app/src/client/components/Admin/App/ConfirmModal.tsx @@ -1,21 +1,20 @@ import type { FC } from 'react'; import React from 'react'; - import { useTranslation } from 'next-i18next'; -import { - Modal, ModalHeader, ModalBody, ModalFooter, -} from 'reactstrap'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; type ConfirmModalProps = { - isModalOpen: boolean - warningMessage: string - supplymentaryMessage: string | null - confirmButtonTitle: string - onConfirm?: () => Promise - onCancel?: () => void + isModalOpen: boolean; + warningMessage: string; + supplymentaryMessage: string | null; + confirmButtonTitle: string; + onConfirm?: () => Promise; + onCancel?: () => void; }; -export const ConfirmModal: FC = (props: ConfirmModalProps) => { +export const ConfirmModal: FC = ( + props: ConfirmModalProps, +) => { const { t } = useTranslation(); const onCancel = () => { @@ -38,20 +37,18 @@ export const ConfirmModal: FC = (props: ConfirmModalProps) => {props.warningMessage} - { - props.supplymentaryMessage != null && ( - <> -
-
- - <> - error - {props.supplymentaryMessage} - - - - ) - } + {props.supplymentaryMessage != null && ( + <> +
+
+ + <> + error + {props.supplymentaryMessage} + + + + )}
-
+
@@ -76,10 +83,17 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element

)} -

- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

+
@@ -104,10 +118,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element /> @@ -122,10 +150,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element /> @@ -140,10 +182,24 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element /> diff --git a/apps/app/src/client/components/Admin/App/MailSetting.tsx b/apps/app/src/client/components/Admin/App/MailSetting.tsx index 012abf32926..ecd2b37adb0 100644 --- a/apps/app/src/client/components/Admin/App/MailSetting.tsx +++ b/apps/app/src/client/components/Admin/App/MailSetting.tsx @@ -1,21 +1,17 @@ import React, { useCallback, useEffect } from 'react'; - import { useTranslation } from 'next-i18next'; import { useForm } from 'react-hook-form'; import AdminAppContainer from '~/client/services/AdminAppContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { withUnstatedContainers } from '../../UnstatedUtils'; - import { SesSetting } from './SesSetting'; import { SmtpSetting } from './SmtpSetting'; - type Props = { - adminAppContainer: AdminAppContainer, -} - + adminAppContainer: AdminAppContainer; +}; const MailSetting = (props: Props) => { const { t } = useTranslation(['admin', 'commons']); @@ -23,15 +19,13 @@ const MailSetting = (props: Props) => { const transmissionMethods = ['smtp', 'ses']; - const { - register, - handleSubmit, - reset, - watch, - } = useForm(); + const { register, handleSubmit, reset, watch } = useForm(); // Watch the transmission method to dynamically switch between SMTP and SES settings - const currentTransmissionMethod = watch('transmissionMethod', adminAppContainer.state.transmissionMethod || 'smtp'); + const currentTransmissionMethod = watch( + 'transmissionMethod', + adminAppContainer.state.transmissionMethod || 'smtp', + ); // Reset form when adminAppContainer state changes useEffect(() => { @@ -57,61 +51,75 @@ const MailSetting = (props: Props) => { reset, ]); - const onSubmit = useCallback(async(data) => { - try { - // Await all setState completions before API call - await Promise.all([ - adminAppContainer.changeFromAddress(data.fromAddress), - adminAppContainer.changeTransmissionMethod(data.transmissionMethod), - adminAppContainer.changeSmtpHost(data.smtpHost), - adminAppContainer.changeSmtpPort(data.smtpPort), - adminAppContainer.changeSmtpUser(data.smtpUser), - adminAppContainer.changeSmtpPassword(data.smtpPassword), - adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId), - adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey), - ]); - - await adminAppContainer.updateMailSettingHandler(); - toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings'), ns: 'commons' })); - } - catch (err) { - toastError(err); - } - }, [adminAppContainer, t]); + const onSubmit = useCallback( + async (data) => { + try { + // Await all setState completions before API call + await Promise.all([ + adminAppContainer.changeFromAddress(data.fromAddress), + adminAppContainer.changeTransmissionMethod(data.transmissionMethod), + adminAppContainer.changeSmtpHost(data.smtpHost), + adminAppContainer.changeSmtpPort(data.smtpPort), + adminAppContainer.changeSmtpUser(data.smtpUser), + adminAppContainer.changeSmtpPassword(data.smtpPassword), + adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId), + adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey), + ]); + + await adminAppContainer.updateMailSettingHandler(); + toastSuccess( + t('toaster.update_successed', { + target: t('admin:app_setting.mail_settings'), + ns: 'commons', + }), + ); + } catch (err) { + toastError(err); + } + }, + [adminAppContainer, t], + ); async function sendTestEmailHandler() { const { adminAppContainer } = props; try { await adminAppContainer.sendTestEmail(); toastSuccess(t('admin:app_setting.success_to_send_test_email')); - } - catch (err) { + } catch (err) { toastError(err); } } - return (
{!adminAppContainer.state.isMailerSetup && ( -
error {t('admin:app_setting.mailer_is_not_set_up')}
+
+ error{' '} + {t('admin:app_setting.mailer_is_not_set_up')} +
)}
- +
- +
{transmissionMethods.map((method) => { return ( @@ -123,24 +131,41 @@ const MailSetting = (props: Props) => { value={method} {...register('transmissionMethod')} /> - +
); })}
- {currentTransmissionMethod === 'smtp' && } - {currentTransmissionMethod === 'ses' && } + {currentTransmissionMethod === 'smtp' && ( + + )} + {currentTransmissionMethod === 'ses' && ( + + )}
- {adminAppContainer.state.transmissionMethod === 'smtp' && ( - )} @@ -148,12 +173,13 @@ const MailSetting = (props: Props) => {
); - }; /** * Wrapper component for using unstated */ -const MailSettingWrapper = withUnstatedContainers(MailSetting, [AdminAppContainer]); +const MailSettingWrapper = withUnstatedContainers(MailSetting, [ + AdminAppContainer, +]); export default MailSettingWrapper; diff --git a/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx b/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx index 8f73c4e5785..af3fd8bd438 100644 --- a/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx +++ b/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx @@ -1,54 +1,81 @@ import type { FC } from 'react'; -import React, { useState, useCallback } from 'react'; - +import React, { useCallback, useState } from 'react'; import { useTranslation } from 'next-i18next'; import { useMaintenanceModeActions } from '~/client/services/maintenance-mode'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { useIsMaintenanceMode } from '~/states/global'; import { ConfirmModal } from './ConfirmModal'; - export const MaintenanceMode: FC = () => { const { t } = useTranslation(); const isMaintenanceMode = useIsMaintenanceMode(); - const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions(); + const { start: startMaintenanceMode, end: endMaintenanceMode } = + useMaintenanceModeActions(); const [isModalOpen, setModalOpen] = useState(false); - const openModal = useCallback(() => { setModalOpen(true) }, []); + const openModal = useCallback(() => { + setModalOpen(true); + }, []); - const closeModal = useCallback(() => { setModalOpen(false) }, []); + const closeModal = useCallback(() => { + setModalOpen(false); + }, []); - const onConfirmHandler = useCallback(async() => { + const onConfirmHandler = useCallback(async () => { closeModal(); try { if (isMaintenanceMode) { endMaintenanceMode(); - } - else { + } else { startMaintenanceMode(); } - } - catch (err) { - toastError(isMaintenanceMode ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') : t('admin:maintenance_mode.failed_to_start_maintenance_mode')); + } catch (err) { + toastError( + isMaintenanceMode + ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') + : t('admin:maintenance_mode.failed_to_start_maintenance_mode'), + ); } // eslint-disable-next-line max-len - toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode')); - }, [isMaintenanceMode, closeModal, startMaintenanceMode, endMaintenanceMode, t]); + toastSuccess( + isMaintenanceMode + ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') + : t('admin:maintenance_mode.successfully_started_maintenance_mode'), + ); + }, [ + isMaintenanceMode, + closeModal, + startMaintenanceMode, + endMaintenanceMode, + t, + ]); return (
closeModal()} /> @@ -60,8 +87,14 @@ export const MaintenanceMode: FC = () => {

-
diff --git a/apps/app/src/client/components/Admin/App/MaskedInput.tsx b/apps/app/src/client/components/Admin/App/MaskedInput.tsx index 1266c73692a..d9726946c3f 100644 --- a/apps/app/src/client/components/Admin/App/MaskedInput.tsx +++ b/apps/app/src/client/components/Admin/App/MaskedInput.tsx @@ -1,19 +1,18 @@ import type { ChangeEvent } from 'react'; -import { useState, type JSX } from 'react'; - +import { type JSX, useState } from 'react'; import type { UseFormRegister } from 'react-hook-form'; import styles from './MaskedInput.module.scss'; type Props = { - name?: string - readOnly: boolean - value?: string - onChange?: (e: ChangeEvent) => void - tabIndex?: number | undefined + name?: string; + readOnly: boolean; + value?: string; + onChange?: (e: ChangeEvent) => void; + tabIndex?: number | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - register?: UseFormRegister - fieldName?: string + register?: UseFormRegister; + fieldName?: string; }; export default function MaskedInput(props: Props): JSX.Element { @@ -22,18 +21,18 @@ export default function MaskedInput(props: Props): JSX.Element { setPasswordShown(!passwordShown); }; - const { - name, readOnly, value, onChange, tabIndex, register, fieldName, - } = props; + const { name, readOnly, value, onChange, tabIndex, register, fieldName } = + props; // Use register if provided, otherwise use value/onChange - const inputProps = register && fieldName - ? register(fieldName) - : { - name, - value, - onChange, - }; + const inputProps = + register && fieldName + ? register(fieldName) + : { + name, + value, + onChange, + }; return (
@@ -44,13 +43,19 @@ export default function MaskedInput(props: Props): JSX.Element { tabIndex={tabIndex} {...inputProps} /> - +
); } diff --git a/apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx b/apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx index 35bf18e30a5..f8806881c44 100644 --- a/apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx +++ b/apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx @@ -1,12 +1,9 @@ -import { - useState, useCallback, useEffect, type JSX, -} from 'react'; - +import { type JSX, useCallback, useEffect, useState } from 'react'; import { LoadingSpinner } from '@growi/ui/dist/components'; import { useTranslation } from 'next-i18next'; import { apiv3Put } from '~/client/util/apiv3-client'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { useSWRxAppSettings } from '~/stores/admin/app-settings'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; @@ -16,36 +13,53 @@ const PageBulkExportSettings = (): JSX.Element => { const { data, error, mutate } = useSWRxAppSettings(); - const [isBulkExportPagesEnabled, setIsBulkExportPagesEnabled] = useState(data?.isBulkExportPagesEnabled); - const [bulkExportDownloadExpirationSeconds, setBulkExportDownloadExpirationSeconds] = useState(data?.bulkExportDownloadExpirationSeconds); - - const changeBulkExportDownloadExpirationSeconds = (bulkExportDownloadExpirationDays: number) => { - const bulkExportDownloadExpirationSeconds = bulkExportDownloadExpirationDays * 24 * 60 * 60; + const [isBulkExportPagesEnabled, setIsBulkExportPagesEnabled] = useState( + data?.isBulkExportPagesEnabled, + ); + const [ + bulkExportDownloadExpirationSeconds, + setBulkExportDownloadExpirationSeconds, + ] = useState(data?.bulkExportDownloadExpirationSeconds); + + const changeBulkExportDownloadExpirationSeconds = ( + bulkExportDownloadExpirationDays: number, + ) => { + const bulkExportDownloadExpirationSeconds = + bulkExportDownloadExpirationDays * 24 * 60 * 60; setBulkExportDownloadExpirationSeconds(bulkExportDownloadExpirationSeconds); }; - const onSubmitHandler = useCallback(async() => { + const onSubmitHandler = useCallback(async () => { try { await apiv3Put('/app-settings/page-bulk-export-settings', { isBulkExportPagesEnabled, bulkExportDownloadExpirationSeconds, }); - toastSuccess(t('commons:toaster.update_successed', { target: t('app_setting.page_bulk_export_settings') })); - } - catch (err) { + toastSuccess( + t('commons:toaster.update_successed', { + target: t('app_setting.page_bulk_export_settings'), + }), + ); + } catch (err) { toastError(err); } mutate(); - }, [isBulkExportPagesEnabled, bulkExportDownloadExpirationSeconds, mutate, t]); + }, [ + isBulkExportPagesEnabled, + bulkExportDownloadExpirationSeconds, + mutate, + t, + ]); useEffect(() => { if (data?.useOnlyEnvVarForFileUploadType) { setIsBulkExportPagesEnabled(data?.envIsBulkExportPagesEnabled); - } - else { + } else { setIsBulkExportPagesEnabled(data?.isBulkExportPagesEnabled); } - setBulkExportDownloadExpirationSeconds(data?.bulkExportDownloadExpirationSeconds); + setBulkExportDownloadExpirationSeconds( + data?.bulkExportDownloadExpirationSeconds, + ); }, [data]); const isLoading = data === undefined && error === undefined; @@ -68,10 +82,7 @@ const PageBulkExportSettings = (): JSX.Element => {

- +
@@ -81,21 +92,29 @@ const PageBulkExportSettings = (): JSX.Element => { id="cbIsPageBulkExportEnabled" checked={isBulkExportPagesEnabled} disabled={data?.useOnlyEnvVarsForIsBulkExportPagesEnabled} - onChange={e => setIsBulkExportPagesEnabled(e.target.checked)} + onChange={(e) => + setIsBulkExportPagesEnabled(e.target.checked) + } /> -
{data?.useOnlyEnvVarsForIsBulkExportPagesEnabled && (

{/* eslint-disable-next-line react/no-danger */} -

)} @@ -106,6 +125,7 @@ const PageBulkExportSettings = (): JSX.Element => {
@@ -113,11 +133,21 @@ const PageBulkExportSettings = (): JSX.Element => {
-
- ); }; @@ -56,6 +60,8 @@ export { SesSetting }; /** * Wrapper component for using unstated */ -const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]); +const SesSettingWrapper = withUnstatedContainers(SesSetting, [ + AdminAppContainer, +]); export default SesSettingWrapper; diff --git a/apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx b/apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx index 384ebcfa8e1..7451342341b 100644 --- a/apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx +++ b/apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx @@ -1,10 +1,9 @@ import React, { useCallback, useEffect } from 'react'; - import { useTranslation } from 'next-i18next'; import { useForm } from 'react-hook-form'; import AdminAppContainer from '~/client/services/AdminAppContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import loggerFactory from '~/utils/logger'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -12,21 +11,16 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; const logger = loggerFactory('growi:appSettings'); - type Props = { - adminAppContainer: AdminAppContainer, -} + adminAppContainer: AdminAppContainer; +}; const SiteUrlSetting = (props: Props) => { const { t } = useTranslation('admin', { keyPrefix: 'app_setting' }); const { t: tCommon } = useTranslation('commons'); const { adminAppContainer } = props; - const { - register, - handleSubmit, - reset, - } = useForm(); + const { register, handleSubmit, reset } = useForm(); // Reset form when adminAppContainer state changes useEffect(() => { @@ -35,36 +29,47 @@ const SiteUrlSetting = (props: Props) => { }); }, [adminAppContainer.state.siteUrl, reset]); - const onSubmit = useCallback(async(data) => { - try { - // Await setState completion before API call - await adminAppContainer.changeSiteUrl(data.siteUrl); - await adminAppContainer.updateSiteUrlSettingHandler(); - toastSuccess(tCommon('toaster.update_successed', { target: t('site_url.title') })); - } - catch (err) { - toastError(err); - logger.error(err); - } - }, [adminAppContainer, t, tCommon]); + const onSubmit = useCallback( + async (data) => { + try { + // Await setState completion before API call + await adminAppContainer.changeSiteUrl(data.siteUrl); + await adminAppContainer.updateSiteUrlSettingHandler(); + toastSuccess( + tCommon('toaster.update_successed', { target: t('site_url.title') }), + ); + } catch (err) { + toastError(err); + logger.error(err); + } + }, + [adminAppContainer, t, tCommon], + ); return (

{t('site_url.desc')}

- {!adminAppContainer.state.isSetSiteUrl - && (

error {t('site_url.warn')}

)} - - { adminAppContainer.state.siteUrlUseOnlyEnvVars && ( + {!adminAppContainer.state.isSetSiteUrl && ( +

+ error{' '} + {t('site_url.warn')} +

+ )} + + {adminAppContainer.state.siteUrlUseOnlyEnvVars && (

- ) } + )}
- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

- +

{/* eslint-disable-next-line react/no-danger */} - +

@@ -84,20 +89,37 @@ const SiteUrlSetting = (props: Props) => {

{/* eslint-disable-next-line react/no-danger */} - +

@@ -105,7 +127,10 @@ const SiteUrlSetting = (props: Props) => {
- +

{/* eslint-disable-next-line react/no-danger */} - +

- + ); }; @@ -113,6 +138,8 @@ const SiteUrlSetting = (props: Props) => { /** * Wrapper component for using unstated */ -const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AdminAppContainer]); +const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [ + AdminAppContainer, +]); export default SiteUrlSettingWrapper; diff --git a/apps/app/src/client/components/Admin/App/SmtpSetting.tsx b/apps/app/src/client/components/Admin/App/SmtpSetting.tsx index 7b151c44d52..e94eac073e3 100644 --- a/apps/app/src/client/components/Admin/App/SmtpSetting.tsx +++ b/apps/app/src/client/components/Admin/App/SmtpSetting.tsx @@ -1,6 +1,4 @@ - import React from 'react'; - import { useTranslation } from 'next-i18next'; import type { UseFormRegister } from 'react-hook-form'; @@ -8,12 +6,11 @@ import AdminAppContainer from '~/client/services/AdminAppContainer'; import { withUnstatedContainers } from '../../UnstatedUtils'; - type Props = { - adminAppContainer?: AdminAppContainer, + adminAppContainer?: AdminAppContainer; // eslint-disable-next-line @typescript-eslint/no-explicit-any - register: UseFormRegister, -} + register: UseFormRegister; +}; const SmtpSetting = (props: Props): JSX.Element => { const { t } = useTranslation(); @@ -23,51 +20,67 @@ const SmtpSetting = (props: Props): JSX.Element => {
-
-
-
-
diff --git a/apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx b/apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx index f6b0ebe28ea..8c6a46e288d 100644 --- a/apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx +++ b/apps/app/src/client/components/Admin/Common/LabeledProgressBar.tsx @@ -1,18 +1,15 @@ import React, { type JSX } from 'react'; - import { Progress } from 'reactstrap'; type Props = { - header: string, - currentCount: number, - totalCount: number, - isInProgress?: boolean, -} + header: string; + currentCount: number; + totalCount: number; + isInProgress?: boolean; +}; const LabeledProgressBar = (props: Props): JSX.Element => { - const { - header, currentCount, totalCount, isInProgress, - } = props; + const { header, currentCount, totalCount, isInProgress } = props; const progressingColor = isInProgress ? 'info' : 'success'; @@ -20,14 +17,22 @@ const LabeledProgressBar = (props: Props): JSX.Element => { <>
{header} -
{currentCount} / {totalCount}
+
+ {currentCount} / {totalCount} +
- + ); - }; export default LabeledProgressBar; diff --git a/apps/app/src/client/components/Admin/Customize/Customize.jsx b/apps/app/src/client/components/Admin/Customize/Customize.jsx index 960d19d3ea6..26057f0f85b 100644 --- a/apps/app/src/client/components/Admin/Customize/Customize.jsx +++ b/apps/app/src/client/components/Admin/Customize/Customize.jsx @@ -1,6 +1,4 @@ - -import React, { useEffect, useCallback } from 'react'; - +import React, { useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer'; @@ -9,7 +7,6 @@ import { toArrayIfNot } from '~/utils/array-utils'; import loggerFactory from '~/utils/logger'; import { withUnstatedContainers } from '../../UnstatedUtils'; - import CustomizeCssSetting from './CustomizeCssSetting'; import CustomizeFunctionSetting from './CustomizeFunctionSetting'; import CustomizeLayoutSetting from './CustomizeLayoutSetting'; @@ -26,11 +23,10 @@ const logger = loggerFactory('growi:services:AdminCustomizePage'); function Customize(props) { const { adminCustomizeContainer } = props; - const fetchCustomizeSettingsData = useCallback(async() => { + const fetchCustomizeSettingsData = useCallback(async () => { try { await adminCustomizeContainer.retrieveCustomizeData(); - } - catch (err) { + } catch (err) { const errs = toArrayIfNot(err); toastError(errs); logger.error(errs); @@ -41,7 +37,6 @@ function Customize(props) { fetchCustomizeSettingsData(); }, [fetchCustomizeSettingsData]); - return (
@@ -78,10 +73,13 @@ function Customize(props) { ); } -const CustomizePageWithUnstatedContainer = withUnstatedContainers(Customize, [AdminCustomizeContainer]); +const CustomizePageWithUnstatedContainer = withUnstatedContainers(Customize, [ + AdminCustomizeContainer, +]); Customize.propTypes = { - adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired, + adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer) + .isRequired, }; export default CustomizePageWithUnstatedContainer; diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx index 310bc792c2c..44a50c8762e 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx @@ -1,29 +1,23 @@ -import React, { useCallback, useEffect, type JSX } from 'react'; - +import React, { type JSX, useCallback, useEffect } from 'react'; import { useTranslation } from 'next-i18next'; import { useForm } from 'react-hook-form'; import { Card, CardBody } from 'reactstrap'; import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { withUnstatedContainers } from '../../UnstatedUtils'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; type Props = { - adminCustomizeContainer: AdminCustomizeContainer -} + adminCustomizeContainer: AdminCustomizeContainer; +}; const CustomizeCssSetting = (props: Props): JSX.Element => { - const { adminCustomizeContainer } = props; const { t } = useTranslation(); - const { - register, - handleSubmit, - reset, - } = useForm(); + const { register, handleSubmit, reset } = useForm(); // Sync form with container state useEffect(() => { @@ -32,28 +26,38 @@ const CustomizeCssSetting = (props: Props): JSX.Element => { }); }, [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' })); - } - catch (err) { - toastError(err); - } - }, [t, adminCustomizeContainer]); + 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', + }), + ); + } catch (err) { + toastError(err); + } + }, + [t, adminCustomizeContainer], + ); return (
-

{t('admin:customize_settings.custom_css')}

+

+ {t('admin:customize_settings.custom_css')} +

- { t('admin:customize_settings.write_css') }
- { t('admin:customize_settings.reflect_change') } + {t('admin:customize_settings.write_css')} +
+ {t('admin:customize_settings.reflect_change')}
@@ -66,15 +70,19 @@ const CustomizeCssSetting = (props: Props): JSX.Element => { />
- +
); - }; -const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [AdminCustomizeContainer]); +const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [ + AdminCustomizeContainer, +]); export default CustomizeCssSettingWrapper; diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx index a1f50019640..6c84a97f0c9 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx @@ -1,18 +1,15 @@ import React, { type JSX } from 'react'; type Props = { - optionId: string - label: string, - isChecked: boolean, - onChecked: () => void, - children: React.ReactNode, -} + optionId: string; + label: string; + isChecked: boolean; + onChecked: () => void; + children: React.ReactNode; +}; const CustomizeFunctionOption = (props: Props): JSX.Element => { - - const { - optionId, label, isChecked, onChecked, children, - } = props; + const { optionId, label, isChecked, onChecked, children } = props; return ( @@ -31,7 +28,6 @@ const CustomizeFunctionOption = (props: Props): JSX.Element => { {children} ); - }; export default CustomizeFunctionOption; diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx index 136d3c4aa1e..4533da97e6b 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx @@ -1,33 +1,33 @@ -import React, { useCallback, type JSX } from 'react'; - +import React, { type JSX, useCallback } from 'react'; import { useTranslation } from 'next-i18next'; import { Card, CardBody } from 'reactstrap'; import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { withUnstatedContainers } from '../../UnstatedUtils'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; - import CustomizeFunctionOption from './CustomizeFunctionOption'; import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown'; type Props = { - adminCustomizeContainer: AdminCustomizeContainer -} + adminCustomizeContainer: AdminCustomizeContainer; +}; const CustomizeFunctionSetting = (props: Props): JSX.Element => { - const { adminCustomizeContainer } = props; const { t } = useTranslation(); - const onClickSubmit = useCallback(async() => { - + const onClickSubmit = useCallback(async () => { try { await adminCustomizeContainer.updateCustomizeFunction(); - toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.function'), ns: 'commons' })); - } - catch (err) { + toastSuccess( + t('toaster.update_successed', { + target: t('admin:customize_settings.function'), + ns: 'commons', + }), + ); + } catch (err) { toastError(err); } }, [t, adminCustomizeContainer]); @@ -36,24 +36,33 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
-

{t('admin:customize_settings.function')}

+

+ {t('admin:customize_settings.function')} +

{t('admin:customize_settings.function_desc')} -
{ adminCustomizeContainer.switchEnabledAttachTitleHeader() }} + label={t( + 'admin:customize_settings.function_options.attach_title_header', + )} + isChecked={ + adminCustomizeContainer.state.isEnabledAttachTitleHeader + } + onChecked={() => { + adminCustomizeContainer.switchEnabledAttachTitleHeader(); + }} >

- {t('admin:customize_settings.function_options.attach_title_header_desc')} + {t( + 'admin:customize_settings.function_options.attach_title_header_desc', + )}

@@ -61,43 +70,67 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
{ adminCustomizeContainer.switchEnableStaleNotification() }} + label={t( + 'admin:customize_settings.function_options.stale_notification', + )} + isChecked={ + adminCustomizeContainer.state.isEnabledStaleNotification + } + onChecked={() => { + adminCustomizeContainer.switchEnableStaleNotification(); + }} >

- {t('admin:customize_settings.function_options.stale_notification_desc')} + {t( + 'admin:customize_settings.function_options.stale_notification_desc', + )}

@@ -107,12 +140,20 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
{ adminCustomizeContainer.switchIsAllReplyShown() }} + label={t( + 'admin:customize_settings.function_options.show_all_reply_comments', + )} + isChecked={ + adminCustomizeContainer.state.isAllReplyShown || false + } + onChecked={() => { + adminCustomizeContainer.switchIsAllReplyShown(); + }} >

- {t('admin:customize_settings.function_options.show_all_reply_comments_desc')} + {t( + 'admin:customize_settings.function_options.show_all_reply_comments_desc', + )}

@@ -122,12 +163,21 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
{ adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }} + label={t( + 'admin:customize_settings.function_options.select_search_scope_children_as_default', + )} + isChecked={ + adminCustomizeContainer.state + .isSearchScopeChildrenAsDefault || false + } + onChecked={() => { + adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault(); + }} >

- {t('admin:customize_settings.function_options.select_search_scope_children_as_default_desc')} + {t( + 'admin:customize_settings.function_options.select_search_scope_children_as_default_desc', + )}

@@ -137,25 +187,36 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
{ adminCustomizeContainer.switchShowPageSideAuthors() }} + onChecked={() => { + adminCustomizeContainer.switchShowPageSideAuthors(); + }} >

- {t('admin:customize_settings.function_options.show_page_side_authors_desc')} + {t( + 'admin:customize_settings.function_options.show_page_side_authors_desc', + )}

- +
); - }; -const CustomizeFunctionSettingWrapper = withUnstatedContainers(CustomizeFunctionSetting, [AdminCustomizeContainer]); +const CustomizeFunctionSettingWrapper = withUnstatedContainers( + CustomizeFunctionSetting, + [AdminCustomizeContainer], +); export default CustomizeFunctionSettingWrapper; diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx index 7197e2b4b62..800f4fa89ef 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx @@ -1,16 +1,14 @@ -import React, { - useCallback, useEffect, useState, type JSX, -} from 'react'; - +import React, { type JSX, useCallback, useEffect, useState } from 'react'; import { LoadingSpinner } from '@growi/ui/dist/components'; import { useTranslation } from 'next-i18next'; -import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useNextThemes } from '~/stores-universal/use-next-themes'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { useSWRxLayoutSetting } from '~/stores/admin/customize'; +import { useNextThemes } from '~/stores-universal/use-next-themes'; const useIsContainerFluid = () => { - const { data: layoutSetting, update: updateLayoutSetting } = useSWRxLayoutSetting(); + const { data: layoutSetting, update: updateLayoutSetting } = + useSWRxLayoutSetting(); const [isContainerFluid, setIsContainerFluid] = useState(); useEffect(() => { @@ -29,15 +27,22 @@ const CustomizeLayoutSetting = (): JSX.Element => { const { resolvedTheme } = useNextThemes(); - const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } = useIsContainerFluid(); + const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } = + useIsContainerFluid(); - const onClickSubmit = useCallback(async() => { - if (isContainerFluid == null) { return } + const onClickSubmit = useCallback(async () => { + if (isContainerFluid == null) { + return; + } try { await updateLayoutSetting({ isContainerFluid }); - toastSuccess(t('toaster.update_successed', { target: t('customize_settings.layout'), ns: 'commons' })); - } - catch (err) { + toastSuccess( + t('toaster.update_successed', { + target: t('customize_settings.layout'), + ns: 'commons', + }), + ); + } catch (err) { toastError(err); } }, [isContainerFluid, updateLayoutSetting, t]); @@ -54,15 +59,18 @@ const CustomizeLayoutSetting = (): JSX.Element => {
-

{t('customize_settings.layout')}

+

+ {t('customize_settings.layout')} +

-
setIsContainerFluid(false)} - role="button" + aria-pressed={!isContainerFluid} > {/* eslint-disable-next-line @next/next/no-img-element */} {
{t('customize_settings.layout_options.default')}
-
+
-
setIsContainerFluid(true)} - role="button" + aria-pressed={isContainerFluid} > {/* eslint-disable-next-line @next/next/no-img-element */} {
{t('customize_settings.layout_options.expanded')}
-
+
- +
diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx index 864b5bb5c4e..d2dd19aefaa 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx @@ -1,11 +1,12 @@ -import React, { useCallback, useState, type JSX } from 'react'; - +import React, { type JSX, useCallback, useState } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import ImageCropModal from '~/client/components/Common/ImageCropModal'; import { - apiv3Delete, apiv3PostForm, apiv3Put, + apiv3Delete, + apiv3PostForm, + apiv3Put, } from '~/client/util/apiv3-client'; import { toastError, toastSuccess } from '~/client/util/toastr'; import { useIsDefaultLogo } from '~/states/global'; @@ -13,20 +14,23 @@ import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; - const DEFAULT_LOGO = '/images/logo.svg'; const CUSTOMIZED_LOGO = '/attachment/brand-logo'; const CustomizeLogoSetting = (): JSX.Element => { - const { t } = useTranslation(); const isDefaultLogo = useIsDefaultLogo(); const isCustomizedLogoUploaded = useAtomValue(isCustomizedLogoUploadedAtom); const setIsCustomizedLogoUploaded = useSetAtom(isCustomizedLogoUploadedAtom); - const [uploadLogoSrc, setUploadLogoSrc] = useState(null); - const [isImageCropModalShow, setIsImageCropModalShow] = useState(false); - const [isDefaultLogoSelected, setIsDefaultLogoSelected] = useState(isDefaultLogo ?? true); + const [uploadLogoSrc, setUploadLogoSrc] = useState< + ArrayBuffer | string | null + >(null); + const [isImageCropModalShow, setIsImageCropModalShow] = + useState(false); + const [isDefaultLogoSelected, setIsDefaultLogoSelected] = useState( + isDefaultLogo ?? true, + ); const [retrieveError, setRetrieveError] = useState(); const onSelectFile = useCallback((e: React.ChangeEvent) => { @@ -40,10 +44,16 @@ const CustomizeLogoSetting = (): JSX.Element => { const onClickSubmit = useCallback(async () => { try { - await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected }); - toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' })); - } - catch (err) { + await apiv3Put('/customize-setting/customize-logo', { + isDefaultLogo: isDefaultLogoSelected, + }); + toastSuccess( + t('toaster.update_successed', { + target: t('admin:customize_settings.custom_logo'), + ns: 'commons', + }), + ); + } catch (err) { toastError(err); } }, [t, isDefaultLogoSelected]); @@ -52,37 +62,49 @@ const CustomizeLogoSetting = (): JSX.Element => { try { await apiv3Delete('/customize-setting/delete-brand-logo'); setIsCustomizedLogoUploaded(false); - toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' })); - } - catch (err) { + toastSuccess( + t('toaster.update_successed', { + target: t('admin:customize_settings.current_logo'), + ns: 'commons', + }), + ); + } catch (err) { toastError(err); setRetrieveError(err); throw new Error('Failed to delete logo'); } }, [setIsCustomizedLogoUploaded, t]); - - const processImageCompletedHandler = useCallback(async (croppedImage) => { - try { - const formData = new FormData(); - formData.append('file', croppedImage); - await apiv3PostForm('/customize-setting/upload-brand-logo', formData); - setIsCustomizedLogoUploaded(true); - toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' })); - } - catch (err) { - toastError(err); - setRetrieveError(err); - throw new Error('Failed to upload brand logo'); - } - }, [setIsCustomizedLogoUploaded, t]); + const processImageCompletedHandler = useCallback( + async (croppedImage) => { + try { + const formData = new FormData(); + formData.append('file', croppedImage); + await apiv3PostForm('/customize-setting/upload-brand-logo', formData); + setIsCustomizedLogoUploaded(true); + toastSuccess( + t('toaster.update_successed', { + target: t('admin:customize_settings.current_logo'), + ns: 'commons', + }), + ); + } catch (err) { + toastError(err); + setRetrieveError(err); + throw new Error('Failed to upload brand logo'); + } + }, + [setIsCustomizedLogoUploaded, t], + ); return (
-

{t('admin:customize_settings.custom_logo')}

+

+ {t('admin:customize_settings.custom_logo')} +

@@ -94,14 +116,23 @@ const CustomizeLogoSetting = (): JSX.Element => { form="formImageType" name="imagetypeForm[isDefaultLogo]" checked={isDefaultLogoSelected} - onChange={() => { setIsDefaultLogoSelected(true) }} + onChange={() => { + setIsDefaultLogoSelected(true); + }} /> -

- + {t('admin:customize_settings.default_logo')}

@@ -113,24 +144,38 @@ const CustomizeLogoSetting = (): JSX.Element => { form="formImageType" name="imagetypeForm[isDefaultLogo]" checked={!isDefaultLogoSelected} - onChange={() => { setIsDefaultLogoSelected(false) }} + onChange={() => { + setIsDefaultLogoSelected(false); + }} /> -

- +
{isCustomizedLogoUploaded && ( <>

- +

- @@ -138,16 +183,28 @@ const CustomizeLogoSetting = (): JSX.Element => {
-
- +
@@ -162,9 +219,6 @@ const CustomizeLogoSetting = (): JSX.Element => { />
); - - }; - export default CustomizeLogoSetting; diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx index de1af31cdd7..5e6527bb0a5 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, type JSX } from 'react'; - +import React, { type JSX, useCallback, useEffect } from 'react'; import { useTranslation } from 'next-i18next'; import { useForm } from 'react-hook-form'; import { PrismAsyncLight } from 'react-syntax-highlighter'; @@ -7,56 +6,66 @@ import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'; import { Card, CardBody } from 'reactstrap'; import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer'; -import { toastSuccess, toastError } from '~/client/util/toastr'; +import { toastError, toastSuccess } from '~/client/util/toastr'; import { withUnstatedContainers } from '../../UnstatedUtils'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; type Props = { - adminCustomizeContainer: AdminCustomizeContainer -} + adminCustomizeContainer: AdminCustomizeContainer; +}; const CustomizeNoscriptSetting = (props: Props): JSX.Element => { - const { adminCustomizeContainer } = props; const { t } = useTranslation(); - const { - register, - handleSubmit, - reset, - } = useForm(); + const { register, handleSubmit, reset } = useForm(); // Sync form with container state useEffect(() => { reset({ - customizeNoscript: adminCustomizeContainer.state.currentCustomizeNoscript || '', + 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' })); - } - catch (err) { - toastError(err); - } - }, [t, adminCustomizeContainer]); + 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', + }), + ); + } catch (err) { + toastError(err); + } + }, + [t, adminCustomizeContainer], + ); return (
-

{t('admin:customize_settings.custom_noscript')}

+

+ {t('admin:customize_settings.custom_noscript')} +

@@ -70,22 +79,24 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => { />
- +
- + {` - ) } + title="Draw.io editor" + > + )}
- ) } + )} ); diff --git a/apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx b/apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx index fba4e4ba900..5c8343b0098 100644 --- a/apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx +++ b/apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx @@ -1,11 +1,9 @@ import type { JSX } from 'react'; - import { useDrawioModalForEditorStatus } from '@growi/editor/dist/states/modal/drawio-for-editor'; import { useLazyLoader } from '~/components/utils/use-lazy-loader'; import { useDrawioModalStatus } from '~/states/ui/modal/drawio'; - type DrawioModalProps = Record; export const DrawioModalLazyLoaded = (): JSX.Element => { @@ -17,7 +15,7 @@ export const DrawioModalLazyLoaded = (): JSX.Element => { const DrawioModal = useLazyLoader( 'drawio-modal', - () => import('./DrawioModal').then(mod => ({ default: mod.DrawioModal })), + () => import('./DrawioModal').then((mod) => ({ default: mod.DrawioModal })), isOpened || isOpenedInEditor, ); diff --git a/apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx b/apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx index 4e516311335..141d780217c 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx @@ -1,5 +1,4 @@ import { type FC, useState } from 'react'; - import type { EditingClient } from '@growi/editor'; import { UserPicture } from '@growi/ui/dist/components'; import { Popover, PopoverBody } from 'reactstrap'; @@ -11,8 +10,8 @@ import styles from './EditingUserList.module.scss'; const userListPopoverClass = styles['user-list-popover'] ?? ''; type Props = { - clientList: EditingClient[] -} + clientList: EditingClient[]; +}; export const EditingUserList: FC = ({ clientList }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -29,7 +28,7 @@ export const EditingUserList: FC = ({ clientList }) => { return (
- {firstFourUsers.map(editingClient => ( + {firstFourUsers.map((editingClient) => (
= ({ clientList }) => { {remainingUsers.length > 0 && (
- - + diff --git a/apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx b/apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx index 20873060c0d..c4bdc042368 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx @@ -11,18 +11,20 @@ const moduleClass = styles['editor-navbar'] ?? ''; const EditingUsers = (): JSX.Element => { const editingClients = useEditingClients(); - return ( - - ); + return ; }; export const EditorNavbar = (): JSX.Element => { return ( -
-
-
+
+
+ +
+
+ +
); }; diff --git a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx index 9fc1f0b76d4..65b28e7e80d 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx @@ -1,8 +1,10 @@ import { useCallback } from 'react'; - import { useTranslation } from 'next-i18next'; -import { useAiAssistantSidebarStatus, useAiAssistantSidebarActions } from '~/features/openai/client/states'; +import { + useAiAssistantSidebarActions, + useAiAssistantSidebarStatus, +} from '~/features/openai/client/states'; export const EditorAssistantToggleButton = (): JSX.Element => { const { t } = useTranslation(); diff --git a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx index cae247ae0fa..6b2fc4d72be 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx @@ -1,7 +1,6 @@ import type { JSX } from 'react'; - -import { useAtomValue } from 'jotai'; import dynamic from 'next/dynamic'; +import { useAtomValue } from 'jotai'; import { aiEnabledAtom } from '~/states/server-configurations'; import { useDrawerOpened } from '~/states/ui/sidebar'; @@ -10,11 +9,16 @@ import { EditorAssistantToggleButton } from './EditorAssistantToggleButton'; import styles from './EditorNavbarBottom.module.scss'; - const moduleClass = styles['grw-editor-navbar-bottom']; -const SavePageControls = dynamic(() => import('./SavePageControls').then(mod => mod.SavePageControls), { ssr: false }); -const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false }); +const SavePageControls = dynamic( + () => import('./SavePageControls').then((mod) => mod.SavePageControls), + { ssr: false }, +); +const OptionsSelector = dynamic( + () => import('./OptionsSelector').then((mod) => mod.OptionsSelector), + { ssr: false }, +); export const EditorNavbarBottom = (): JSX.Element => { const isAiEnabled = useAtomValue(aiEnabledAtom); @@ -22,19 +26,19 @@ export const EditorNavbarBottom = (): JSX.Element => { return (
-
- +
- {isAiEnabled && ( - - )} + {isAiEnabled && }
diff --git a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx index 09734344399..2f32342ccf6 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx @@ -1,17 +1,21 @@ import React, { - useCallback, useEffect, useState, type JSX, + type JSX, + type ReactNode, + useCallback, + useEffect, + useState, } from 'react'; - -import { - PageGrant, GroupType, getIdForRef, -} from '@growi/core'; +import { GroupType, getIdForRef, PageGrant } from '@growi/core'; import { LoadingSpinner } from '@growi/ui/dist/components'; import { useTranslation } from 'next-i18next'; import { + DropdownItem, + DropdownMenu, + DropdownToggle, + Modal, + ModalBody, + ModalHeader, UncontrolledDropdown, - DropdownToggle, DropdownMenu, DropdownItem, - - Modal, ModalHeader, ModalBody, } from 'reactstrap'; import type { UserRelatedGroupsData } from '~/interfaces/page'; @@ -21,17 +25,25 @@ import { useCurrentPageId } from '~/states/page'; import { useSelectedGrant } from '~/states/ui/editor'; import { useSWRxCurrentGrantData } from '~/stores/page'; - const AVAILABLE_GRANTS = [ { - grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public', + grant: PageGrant.GRANT_PUBLIC, + iconName: 'group', + btnStyleClass: 'outline-info', + label: 'Public', }, { - grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-success', label: 'Anyone with the link', + grant: PageGrant.GRANT_RESTRICTED, + iconName: 'link', + btnStyleClass: 'outline-success', + label: 'Anyone with the link', }, // { grant: 3, iconClass: '', label: 'Specified users only' }, { - grant: PageGrant.GRANT_OWNER, iconName: 'lock', btnStyleClass: 'outline-danger', label: 'Only me', + grant: PageGrant.GRANT_OWNER, + iconName: 'lock', + btnStyleClass: 'outline-danger', + label: 'Only me', }, { grant: PageGrant.GRANT_USER_GROUP, @@ -42,11 +54,10 @@ const AVAILABLE_GRANTS = [ }, ]; - type Props = { - disabled?: boolean, - openInModal?: boolean, -} + disabled?: boolean; + openInModal?: boolean; +}; /** * Page grant select component @@ -54,11 +65,7 @@ type Props = { export const GrantSelector = (props: Props): JSX.Element => { const { t } = useTranslation(); - const { - disabled, - openInModal, - } = props; - + const { disabled, openInModal } = props; const [isSelectGroupModalShown, setIsSelectGroupModalShown] = useState(false); @@ -76,10 +83,12 @@ export const GrantSelector = (props: Props): JSX.Element => { const currentPageGrant = grantData?.grantData.currentPageGrant; if (currentPageGrant == null) return; - const userRelatedGrantedGroups = currentPageGrant.groupGrantData - ?.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted)?.map((group) => { - return { item: group.id, type: group.type }; - }) ?? []; + const userRelatedGrantedGroups = + currentPageGrant.groupGrantData?.userRelatedGroups + .filter((group) => group.status === UserGroupPageGrantStatus.isGranted) + ?.map((group) => { + return { item: group.id, type: group.type }; + }) ?? []; setSelectedGrant({ grant: currentPageGrant.grant, userRelatedGrantedGroups, @@ -98,48 +107,77 @@ export const GrantSelector = (props: Props): JSX.Element => { /** * change event handler for grant selector */ - const changeGrantHandler = useCallback((grant: PageGrant) => { - // select group - if (grant === 5) { - if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant(); - showSelectGroupModal(); - return; - } - - setSelectedGrant({ grant, userRelatedGrantedGroups: undefined }); - }, [setSelectedGrant, showSelectGroupModal, applyCurrentPageGrantToSelectedGrant, selectedGrant?.grant]); + const changeGrantHandler = useCallback( + (grant: PageGrant) => { + // select group + if (grant === 5) { + if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant(); + showSelectGroupModal(); + return; + } - const groupListItemClickHandler = useCallback((clickedGroup: UserRelatedGroupsData) => { - const userRelatedGrantedGroups = selectedGrant?.userRelatedGrantedGroups ?? []; + setSelectedGrant({ grant, userRelatedGrantedGroups: undefined }); + }, + [ + setSelectedGrant, + showSelectGroupModal, + applyCurrentPageGrantToSelectedGrant, + selectedGrant?.grant, + ], + ); - let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups]; - if (userRelatedGrantedGroupsCopy.find(group => getIdForRef(group.item) === clickedGroup.id) == null) { - const grantGroupInfo = { item: clickedGroup.id, type: clickedGroup.type }; - userRelatedGrantedGroupsCopy.push(grantGroupInfo); - } - else { - userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => getIdForRef(group.item) !== clickedGroup.id); - } - setSelectedGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy }); - }, [setSelectedGrant, selectedGrant?.userRelatedGrantedGroups]); + const groupListItemClickHandler = useCallback( + (clickedGroup: UserRelatedGroupsData) => { + const userRelatedGrantedGroups = + selectedGrant?.userRelatedGrantedGroups ?? []; + + let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups]; + if ( + userRelatedGrantedGroupsCopy.find( + (group) => getIdForRef(group.item) === clickedGroup.id, + ) == null + ) { + const grantGroupInfo = { + item: clickedGroup.id, + type: clickedGroup.type, + }; + userRelatedGrantedGroupsCopy.push(grantGroupInfo); + } else { + userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter( + (group) => getIdForRef(group.item) !== clickedGroup.id, + ); + } + setSelectedGrant({ + grant: 5, + userRelatedGrantedGroups: userRelatedGrantedGroupsCopy, + }); + }, + [setSelectedGrant, selectedGrant?.userRelatedGrantedGroups], + ); /** * Render grant selector DOM. */ const renderGrantSelector = useCallback(() => { - - let dropdownToggleBtnColor; - let dropdownToggleLabelElm; - - const userRelatedGrantedGroups = groupGrantData?.userRelatedGroups.filter((group) => { - return selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id); - }) ?? []; - const nonUserRelatedGrantedGroups = groupGrantData?.nonUserRelatedGrantedGroups ?? []; + let dropdownToggleBtnColor: string | undefined; + let dropdownToggleLabelElm: ReactNode | undefined; + + const userRelatedGrantedGroups = + groupGrantData?.userRelatedGroups.filter((group) => { + return selectedGrant?.userRelatedGrantedGroups?.some( + (grantedGroup) => getIdForRef(grantedGroup.item) === group.id, + ); + }) ?? []; + const nonUserRelatedGrantedGroups = + groupGrantData?.nonUserRelatedGrantedGroups ?? []; const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => { - const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups.length > 0) - ? opt.reselectLabel // when grantGroup is selected - : opt.label; + const label = + opt.grant === 5 && + opt.reselectLabel != null && + userRelatedGrantedGroups.length > 0 + ? opt.reselectLabel // when grantGroup is selected + : opt.label; const labelElm = ( @@ -154,24 +192,41 @@ export const GrantSelector = (props: Props): JSX.Element => { dropdownToggleLabelElm = labelElm; } - return changeGrantHandler(opt.grant)}>{labelElm}; + return ( + changeGrantHandler(opt.grant)} + > + {labelElm} + + ); }); // add specified group option - if (selectedGrant?.grant === PageGrant.GRANT_USER_GROUP && (userRelatedGrantedGroups.length > 0 || nonUserRelatedGrantedGroups.length > 0)) { - const grantedGroupNames = [...userRelatedGrantedGroups.map(group => group.name), ...nonUserRelatedGrantedGroups.map(group => group.name)]; + if ( + selectedGrant?.grant === PageGrant.GRANT_USER_GROUP && + (userRelatedGrantedGroups.length > 0 || + nonUserRelatedGrantedGroups.length > 0) + ) { + const grantedGroupNames = [ + ...userRelatedGrantedGroups.map((group) => group.name), + ...nonUserRelatedGrantedGroups.map((group) => group.name), + ]; const labelElm = ( account_tree - {grantedGroupNames.length > 1 + {grantedGroupNames.length > 1 ? ( // substring for group name truncate - ? ( - - {`${grantedGroupNames[0].substring(0, 30)}, ... `} - +{grantedGroupNames.length - 1} + + {`${grantedGroupNames[0].substring(0, 30)}, ... `} + + +{grantedGroupNames.length - 1} - ) : grantedGroupNames[0].substring(0, 30)} + + ) : ( + grantedGroupNames[0].substring(0, 30) + )} ); @@ -179,7 +234,9 @@ export const GrantSelector = (props: Props): JSX.Element => { // set dropdownToggleLabelElm dropdownToggleLabelElm = labelElm; - dropdownMenuElems.push({labelElm}); + dropdownMenuElems.push( + {labelElm}, + ); } return ( @@ -193,13 +250,23 @@ export const GrantSelector = (props: Props): JSX.Element => { > {dropdownToggleLabelElm} - + {dropdownMenuElems}
); - }, [changeGrantHandler, disabled, groupGrantData, selectedGrant, t, openInModal]); + }, [ + changeGrantHandler, + disabled, + groupGrantData, + selectedGrant, + t, + openInModal, + ]); /** * Render select grantgroup modal. @@ -224,18 +291,26 @@ export const GrantSelector = (props: Props): JSX.Element => { return (

{t('user_group.belonging_to_no_group')}

- { currentUser?.admin && ( -

login{t('user_group.manage_user_groups')}

- ) } + {currentUser?.admin && ( +

+ + login + {t('user_group.manage_user_groups')} + +

+ )}
); } return (
- { userRelatedGroups.map((group) => { - const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id); - const cannotGrantGroup = group.status === UserGroupPageGrantStatus.cannotGrant; + {userRelatedGroups.map((group) => { + const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some( + (grantedGroup) => getIdForRef(grantedGroup.item) === group.id, + ); + const cannotGrantGroup = + group.status === UserGroupPageGrantStatus.cannotGrant; const activeClass = isGroupGranted ? 'active' : ''; return ( @@ -246,14 +321,22 @@ export const GrantSelector = (props: Props): JSX.Element => { onClick={() => groupListItemClickHandler(group)} disabled={cannotGrantGroup} > - +

{group.name}

- {group.type === GroupType.externalUserGroup && {group.provider}} + {group.type === GroupType.externalUserGroup && ( + + {group.provider} + + )} {/* TODO: Replace
(TBD) List group members
*/} ); - }) } - { nonUserRelatedGrantedGroups.map((group) => { + })} + {nonUserRelatedGrantedGroups.map((group) => { return ( ); - }) } - + })} +
); - - }, [currentUser?.admin, groupListItemClickHandler, shouldFetch, t, groupGrantData, selectedGrant?.userRelatedGrantedGroups]); + }, [ + currentUser?.admin, + groupListItemClickHandler, + shouldFetch, + t, + groupGrantData, + selectedGrant?.userRelatedGrantedGroups, + ]); const renderModalCloseButton = useCallback(() => { return ( @@ -284,27 +383,30 @@ export const GrantSelector = (props: Props): JSX.Element => { close ); - }, [setIsSelectGroupModalShown]); + }, []); return ( <> - { renderGrantSelector() } + {renderGrantSelector()} {/* render modal */} - { !disabled && currentUser != null && ( + {!disabled && currentUser != null && ( setIsSelectGroupModalShown(false)} centered > - setIsSelectGroupModalShown(false)} className="fs-5 text-muted fw-bold pb-2" close={renderModalCloseButton()}> + setIsSelectGroupModalShown(false)} + className="fs-5 text-muted fw-bold pb-2" + close={renderModalCloseButton()} + > {t('user_group.select_group')} - - {renderSelectGroupModalContent()} - + {renderSelectGroupModalContent()} - ) } + )} ); }; diff --git a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx index 2139724f132..b9726a926e4 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx @@ -1,33 +1,41 @@ -import React, { - memo, useCallback, useMemo, useState, type JSX, -} from 'react'; - +import React, { type JSX, memo, useCallback, useMemo, useState } from 'react'; +import Image from 'next/image'; import { - type EditorTheme, type KeyMapMode, PasteMode, AllPasteMode, DEFAULT_KEYMAP, DEFAULT_PASTE_MODE, DEFAULT_THEME, + AllPasteMode, + DEFAULT_KEYMAP, + DEFAULT_PASTE_MODE, + DEFAULT_THEME, + type EditorTheme, + type KeyMapMode, + PasteMode, } from '@growi/editor'; import { useAtomValue } from 'jotai'; import { useTranslation } from 'next-i18next'; -import Image from 'next/image'; import { - Dropdown, DropdownToggle, DropdownMenu, Input, FormGroup, + Dropdown, + DropdownMenu, + DropdownToggle, + FormGroup, + Input, } from 'reactstrap'; import { isIndentSizeForcedAtom } from '~/states/server-configurations'; import { useDeviceLargerThanMd } from '~/states/ui/device'; -import { useCurrentIndentSize, useCurrentIndentSizeActions } from '~/states/ui/editor'; +import { + useCurrentIndentSize, + useCurrentIndentSizeActions, +} from '~/states/ui/editor'; import { useEditorSettings } from '~/stores/editor'; type RadioListItemProps = { - onClick: () => void, - icon?: React.ReactNode, - text: string, - checked?: boolean -} + onClick: () => void; + icon?: React.ReactNode; + text: string; + checked?: boolean; +}; const RadioListItem = (props: RadioListItemProps): JSX.Element => { - const { - onClick, icon, text, checked, - } = props; + const { onClick, icon, text, checked } = props; return (
  • { checked={checked} /> {icon} - +
  • ); }; - type SelectorProps = { - header: string, - onClickBefore: () => void, - items: JSX.Element, -} + header: string; + onClickBefore: () => void; + items: JSX.Element; +}; const Selector = (props: SelectorProps): JSX.Element => { - const { header, onClickBefore, items } = props; return (
    -
    -
      - {items} -
    +
      {items}
    ); - }; - type EditorThemeToLabel = { [key in EditorTheme]: string; -} +}; const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = { defaultlight: 'DefaultLight', @@ -87,33 +100,47 @@ const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = { kimbie: 'Kimbie', }; -const ThemeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { - - const { t } = useTranslation(); - const { data: editorSettings, update } = useEditorSettings(); - const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME; - - const listItems = useMemo(() => ( - <> - {(Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map((theme) => { - const themeLabel = EDITORTHEME_LABEL_MAP[theme]; - return ( - update({ theme })} text={themeLabel} checked={theme === selectedTheme} /> - ); - })} - - ), [update, selectedTheme]); +const ThemeSelector = memo( + ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { + const { t } = useTranslation(); + const { data: editorSettings, update } = useEditorSettings(); + const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME; + + const listItems = useMemo( + () => ( + <> + {(Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map( + (theme) => { + const themeLabel = EDITORTHEME_LABEL_MAP[theme]; + return ( + update({ theme })} + text={themeLabel} + checked={theme === selectedTheme} + /> + ); + }, + )} + + ), + [update, selectedTheme], + ); - return ( - - ); -}); + return ( + + ); + }, +); ThemeSelector.displayName = 'ThemeSelector'; - type KeyMapModeToLabel = { [key in KeyMapMode]: string; -} +}; const KEYMAP_LABEL_MAP: KeyMapModeToLabel = { default: 'Default', @@ -122,98 +149,138 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = { vscode: 'Visual Studio Code', }; -const KeymapSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { - - const { t } = useTranslation(); - const { data: editorSettings, update } = useEditorSettings(); - const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP; - - const listItems = useMemo(() => ( - <> - {(Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => { - const keymapLabel = KEYMAP_LABEL_MAP[keymapMode]; - const icon = (keymapMode !== 'default') - ? {keymapMode} - : null; - return ( - update({ keymapMode })} icon={icon} text={keymapLabel} checked={keymapMode === selectedKeymapMode} /> - ); - })} - - ), [update, selectedKeymapMode]); - +const KeymapSelector = memo( + ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { + const { t } = useTranslation(); + const { data: editorSettings, update } = useEditorSettings(); + const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP; + + const listItems = useMemo( + () => ( + <> + {(Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => { + const keymapLabel = KEYMAP_LABEL_MAP[keymapMode]; + const icon = + keymapMode !== 'default' ? ( + {keymapMode} + ) : null; + return ( + update({ keymapMode })} + icon={icon} + text={keymapLabel} + checked={keymapMode === selectedKeymapMode} + /> + ); + })} + + ), + [update, selectedKeymapMode], + ); - return ( - - ); -}); + return ( + + ); + }, +); KeymapSelector.displayName = 'KeymapSelector'; - const TYPICAL_INDENT_SIZE = [2, 4]; -const IndentSizeSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { - - const { t } = useTranslation(); - const currentIndentSize = useCurrentIndentSize(); - const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions(); - - const listItems = useMemo(() => ( - <> - {TYPICAL_INDENT_SIZE.map((indent) => { - return ( - mutateCurrentIndentSize(indent)} text={indent.toString()} checked={indent === currentIndentSize} /> - ); - })} - - ), [currentIndentSize, mutateCurrentIndentSize]); +const IndentSizeSelector = memo( + ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { + const { t } = useTranslation(); + const currentIndentSize = useCurrentIndentSize(); + const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions(); + + const listItems = useMemo( + () => ( + <> + {TYPICAL_INDENT_SIZE.map((indent) => { + return ( + mutateCurrentIndentSize(indent)} + text={indent.toString()} + checked={indent === currentIndentSize} + /> + ); + })} + + ), + [currentIndentSize, mutateCurrentIndentSize], + ); - return ( - - ); -}); + return ( + + ); + }, +); IndentSizeSelector.displayName = 'IndentSizeSelector'; +const PasteSelector = memo( + ({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { + const { t } = useTranslation(); + const { data: editorSettings, update } = useEditorSettings(); + const selectedPasteMode = editorSettings?.pasteMode ?? DEFAULT_PASTE_MODE; + + const listItems = useMemo( + () => ( + <> + {AllPasteMode.map((pasteMode) => { + return ( + update({ pasteMode })} + text={t(`page_edit.paste.${pasteMode}`) ?? ''} + checked={pasteMode === selectedPasteMode} + /> + ); + })} + + ), + [update, t, selectedPasteMode], + ); -const PasteSelector = memo(({ onClickBefore }: { onClickBefore: () => void }): JSX.Element => { - - const { t } = useTranslation(); - const { data: editorSettings, update } = useEditorSettings(); - const selectedPasteMode = editorSettings?.pasteMode ?? DEFAULT_PASTE_MODE; - - const listItems = useMemo(() => ( - <> - {(AllPasteMode).map((pasteMode) => { - return ( - update({ pasteMode })} text={t(`page_edit.paste.${pasteMode}`) ?? ''} checked={pasteMode === selectedPasteMode} /> - ); - })} - - ), [update, t, selectedPasteMode]); - - return ( - - ); -}); + return ( + + ); + }, +); PasteSelector.displayName = 'PasteSelector'; - type SwitchItemProps = { - inputId: string, - onChange: () => void, - checked: boolean, - text: string, + inputId: string; + onChange: () => void; + checked: boolean; + text: string; }; const SwitchItem = memo((props: SwitchItemProps): JSX.Element => { - const { - inputId, onChange, checked, text, - } = props; + const { inputId, onChange, checked, text } = props; return ( - ); }); @@ -265,29 +332,32 @@ const ConfigurationSelector = memo((): JSX.Element => { }); ConfigurationSelector.displayName = 'ConfigurationSelector'; - type ChangeStateButtonProps = { - onClick: () => void, - header: string, - data: string, - disabled?: boolean, -} + onClick: () => void; + header: string; + data: string; + disabled?: boolean; +}; const ChangeStateButton = memo((props: ChangeStateButtonProps): JSX.Element => { - const { - onClick, header, data, disabled, - } = props; + const { onClick, header, data, disabled } = props; return ( - ); }); - const OptionsStatus = { Home: 'Home', Theme: 'Theme', @@ -295,10 +365,9 @@ const OptionsStatus = { Indent: 'Indent', Paste: 'Paste', } as const; -type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus]; +type OptionStatus = (typeof OptionsStatus)[keyof typeof OptionsStatus]; export const OptionsSelector = (): JSX.Element => { - const { t } = useTranslation(); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -309,12 +378,24 @@ export const OptionsSelector = (): JSX.Element => { const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom); const [isDeviceLargerThanMd] = useDeviceLargerThanMd(); - if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) { + if ( + editorSettings == null || + currentIndentSize == null || + isIndentSizeForced == null + ) { return <>; } return ( - { setStatus(OptionsStatus.Home); setDropdownOpen(!dropdownOpen) }} direction="up" className=""> + { + setStatus(OptionsStatus.Home); + setDropdownOpen(!dropdownOpen); + }} + direction="up" + className="" + > { `} > settings - { - isDeviceLargerThanMd - ? - : <> - } + {isDeviceLargerThanMd ? ( + {t('page_edit.editor_config')} + ) : ( + <> + )} - { - status === OptionsStatus.Home && ( -
    - -
    - setStatus(OptionsStatus.Theme)} - header={t('page_edit.theme')} - data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''} - /> -
    - setStatus(OptionsStatus.Keymap)} - header={t('page_edit.keymap')} - data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''} - /> -
    - setStatus(OptionsStatus.Indent)} - header={t('page_edit.indent')} - data={currentIndentSize.toString() ?? ''} - /> -
    - setStatus(OptionsStatus.Paste)} - header={t('page_edit.paste.title')} - data={t(`page_edit.paste.${editorSettings.pasteMode ?? PasteMode.both}`) ?? ''} - /> -
    - -
    - ) - } + {status === OptionsStatus.Home && ( +
    + + {t('page_edit.editor_config')} + +
    + setStatus(OptionsStatus.Theme)} + header={t('page_edit.theme')} + data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''} + /> +
    + setStatus(OptionsStatus.Keymap)} + header={t('page_edit.keymap')} + data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''} + /> +
    + setStatus(OptionsStatus.Indent)} + header={t('page_edit.indent')} + data={currentIndentSize.toString() ?? ''} + /> +
    + setStatus(OptionsStatus.Paste)} + header={t('page_edit.paste.title')} + data={ + t( + `page_edit.paste.${editorSettings.pasteMode ?? PasteMode.both}`, + ) ?? '' + } + /> +
    + +
    + )} {status === OptionsStatus.Theme && ( setStatus(OptionsStatus.Home)} /> - ) - } + )} {status === OptionsStatus.Keymap && ( setStatus(OptionsStatus.Home)} /> - ) - } + )} {status === OptionsStatus.Indent && ( - setStatus(OptionsStatus.Home)} /> - ) - } + setStatus(OptionsStatus.Home)} + /> + )} {status === OptionsStatus.Paste && ( setStatus(OptionsStatus.Home)} /> )} diff --git a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx index c66e6886280..05040a7a344 100644 --- a/apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx +++ b/apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx @@ -1,26 +1,38 @@ -import React, { - useCallback, useState, useEffect, type JSX, -} from 'react'; - +import React, { type JSX, useCallback, useEffect, useState } from 'react'; import { PageGrant } from '@growi/core'; import { globalEventTarget } from '@growi/core/dist/utils'; -import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils'; +import { + isTopPage, + isUsersProtectedPages, +} from '@growi/core/dist/utils/page-path-utils'; import { LoadingSpinner } from '@growi/ui/dist/components'; import { useAtomValue } from 'jotai'; import { useTranslation } from 'next-i18next'; import { - UncontrolledButtonDropdown, Button, - DropdownToggle, DropdownMenu, DropdownItem, Modal, + Button, + DropdownItem, + DropdownMenu, + DropdownToggle, + Modal, + UncontrolledButtonDropdown, } from 'reactstrap'; -import { useIsEditable, useCurrentPageData, useCurrentPagePath } from '~/states/page'; +import { + useCurrentPageData, + useCurrentPagePath, + useIsEditable, +} from '~/states/page'; import { isAclEnabledAtom, isSlackConfiguredAtom, } from '~/states/server-configurations'; import { useDeviceLargerThanMd } from '~/states/ui/device'; import { - useEditorMode, useSelectedGrant, useWaitingSaveProcessing, useIsSlackEnabled, EditorMode, + EditorMode, + useEditorMode, + useIsSlackEnabled, + useSelectedGrant, + useWaitingSaveProcessing, } from '~/states/ui/editor'; import { useSWRxSlackChannels } from '~/stores/editor'; import loggerFactory from '~/utils/logger'; @@ -28,18 +40,19 @@ import loggerFactory from '~/utils/logger'; import { NotAvailable } from '../../NotAvailable'; import { SlackNotification } from '../../SlackNotification'; import type { SaveOptions } from '../PageEditor'; - import { GrantSelector } from './GrantSelector'; - const logger = loggerFactory('growi:SavePageControls'); - -const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean }) => { - +const SavePageButton = (props: { + slackChannels: string; + isSlackEnabled?: boolean; + isDeviceLargerThanMd?: boolean; +}) => { const { t } = useTranslation(); const _isWaitingSaveProcessing = useWaitingSaveProcessing(); - const [isSavePageModalShown, setIsSavePageModalShown] = useState(false); + const [isSavePageModalShown, setIsSavePageModalShown] = + useState(false); const [selectedGrant] = useSelectedGrant(); const { slackChannels, isSlackEnabled = false, isDeviceLargerThanMd } = props; @@ -48,35 +61,52 @@ const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean const save = useCallback(async (): Promise => { // save - globalEventTarget.dispatchEvent(new CustomEvent('saveAndReturnToView', { - detail: { - wip: false, slackChannels, isSlackEnabled, - }, - })); + globalEventTarget.dispatchEvent( + new CustomEvent('saveAndReturnToView', { + detail: { + wip: false, + slackChannels, + isSlackEnabled, + }, + }), + ); }, [isSlackEnabled, slackChannels]); const saveAndOverwriteScopesOfDescendants = useCallback(() => { // save - globalEventTarget.dispatchEvent(new CustomEvent('saveAndReturnToView', { - detail: { - wip: false, overwriteScopesOfDescendants: true, slackChannels, isSlackEnabled, - }, - })); + globalEventTarget.dispatchEvent( + new CustomEvent('saveAndReturnToView', { + detail: { + wip: false, + overwriteScopesOfDescendants: true, + slackChannels, + isSlackEnabled, + }, + }), + ); }, [isSlackEnabled, slackChannels]); const saveAndMakeWip = useCallback(() => { // save - globalEventTarget.dispatchEvent(new CustomEvent('saveAndReturnToView', { - detail: { - wip: true, slackChannels, isSlackEnabled, - }, - })); + globalEventTarget.dispatchEvent( + new CustomEvent('saveAndReturnToView', { + detail: { + wip: true, + slackChannels, + isSlackEnabled, + }, + }), + ); }, [isSlackEnabled, slackChannels]); const labelSubmitButton = t('Update'); - const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton }); + const labelOverwriteScopes = t('page_edit.overwrite_scopes', { + operation: labelSubmitButton, + }); const labelUnpublishPage = t('wip_page.save_as_wip'); - const restrictedGrantOverrideErrorTitle = t('Not available when "anyone with the link" is selected'); + const restrictedGrantOverrideErrorTitle = t( + 'Not available when "anyone with the link" is selected', + ); return ( <> @@ -89,67 +119,89 @@ const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean onClick={save} disabled={isWaitingSaveProcessing} > - {isWaitingSaveProcessing && ( - - )} + {isWaitingSaveProcessing && } {labelSubmitButton} - { - isDeviceLargerThanMd ? ( - <> - - + {isDeviceLargerThanMd ? ( + <> + + + + + {labelOverwriteScopes} + + + + {labelUnpublishPage} + + + + ) : ( + <> + setIsSavePageModalShown(true)} + /> + setIsSavePageModalShown(false)} + > +
    - + - + - - - -
    -
    - - ) - } + + +
    + + + )} ); }; - export const SavePageControls = (): JSX.Element | null => { const { t } = useTranslation('commons'); const currentPage = useCurrentPageData(); @@ -164,7 +216,8 @@ export const SavePageControls = (): JSX.Element | null => { const [isDeviceLargerThanMd] = useDeviceLargerThanMd(); const [slackChannels, setSlackChannels] = useState(''); - const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState(false); + const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = + useState(false); // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332 const slackChannelsDataString = slackChannelsData?.toString(); @@ -175,7 +228,6 @@ export const SavePageControls = (): JSX.Element | null => { } }, [editorMode, setIsSlackEnabled, slackChannelsDataString]); - const slackChannelsChangedHandler = useCallback((slackChannels: string) => { setSlackChannels(slackChannels); }, []); @@ -188,89 +240,92 @@ export const SavePageControls = (): JSX.Element | null => { return null; } - const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? ''); + const isGrantSelectorDisabledPage = + isTopPage(currentPage?.path ?? '') || + isUsersProtectedPages(currentPage?.path ?? ''); return (
    - { - isDeviceLargerThanMd ? ( - <> - { - isSlackConfigured && ( -
    - {isSlackEnabled != null && ( - - )} -
    - ) - } + {isDeviceLargerThanMd ? ( + <> + {isSlackConfigured && ( +
    + {isSlackEnabled != null && ( + + )} +
    + )} - { - isAclEnabled && ( -
    - -
    - ) - } + {isAclEnabled && ( +
    + +
    + )} - - - ) : ( - <> - - - -
    - { - isAclEnabled && ( - <> - - - ) - } + + + ) : ( + <> + + + +
    + {isAclEnabled && ( + <> + + + )} - { - isSlackConfigured && isSlackEnabled != null && ( - <> - - - ) - } -
    - -
    + {isSlackConfigured && isSlackEnabled != null && ( + <> + + + )} +
    +
    - - - ) - } +
    +
    + + )}
    ); }; diff --git a/apps/app/src/client/components/PageEditor/GridEditModal.jsx b/apps/app/src/client/components/PageEditor/GridEditModal.jsx index 3c3cdea6081..7ee08ac877c 100644 --- a/apps/app/src/client/components/PageEditor/GridEditModal.jsx +++ b/apps/app/src/client/components/PageEditor/GridEditModal.jsx @@ -1,10 +1,7 @@ import React from 'react'; - import { useTranslation } from 'next-i18next'; import PropTypes from 'prop-types'; -import { - Modal, ModalHeader, ModalBody, ModalFooter, -} from 'reactstrap'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import BootstrapGrid from '~/client/models/BootstrapGrid'; @@ -19,7 +16,6 @@ const resSizeObj = { [resSizes.MD_SIZE]: { displayText: 'desktop' }, }; class GridEditModal extends React.Component { - constructor(props) { super(props); @@ -73,7 +69,10 @@ class GridEditModal extends React.Component { pasteCodedGrid() { const { colsRatios, responsiveSize } = this.state; - const convertedHTML = geu.convertRatiosAndSizeToHTML(colsRatios, responsiveSize); + const convertedHTML = geu.convertRatiosAndSizeToHTML( + colsRatios, + responsiveSize, + ); const spaceTab = ' '; const pastedGridData = `::: editable-row\n
    \n${spaceTab}
    \n${convertedHTML}\n${spaceTab}
    \n
    \n:::`; @@ -92,16 +91,22 @@ class GridEditModal extends React.Component { const { t } = this.props; const output = Object.entries(resSizeObj).map((responsiveSizeForMap) => { return ( -
    +
    this.checkResposiveSize(responsiveSizeForMap[0])} + onChange={(e) => this.checkResposiveSize(responsiveSizeForMap[0])} /> -
    @@ -119,17 +124,33 @@ class GridEditModal extends React.Component { {gridDivisions.map((gridDivision) => { const numOfDivisions = gridDivision.numberOfGridDivisions; return ( -
    -
    {numOfDivisions} {t('grid_edit.division')}
    +
    +
    + {numOfDivisions} {t('grid_edit.division')} +
    {gridDivision.mapping.map((gridOneDivision) => { const keyOfRow = `${numOfDivisions}-divisions-${gridOneDivision.join('-')}`; return ( - @@ -145,8 +166,10 @@ class GridEditModal extends React.Component { renderPreview() { const { t } = this.props; - const isMdSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE; - const isXsSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE; + const isMdSelected = + this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE; + const isXsSelected = + this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE; return (
    @@ -178,19 +201,20 @@ class GridEditModal extends React.Component { const ratio = isBreakEnabled ? 12 : colsRatio; const key = `grid-preview-col-${i}`; const className = `col-${ratio} grid-edit-border-for-each-cols`; - return ( -
    - ); + return
    ; }); - return ( -
    {convertedHTML}
    - ); + return
    {convertedHTML}
    ; } render() { const { t } = this.props; return ( - + {t('grid_edit.create_bootstrap_4_grid')} @@ -214,7 +238,11 @@ class GridEditModal extends React.Component { > {this.renderSelectedGridPattern()} -
    +
    {this.renderGridDivisionMenu()}
    @@ -231,16 +259,22 @@ class GridEditModal extends React.Component {

    {t('preview')}

    -
    - {this.renderPreview()} -
    +
    {this.renderPreview()}
    - -
    @@ -248,7 +282,6 @@ class GridEditModal extends React.Component { ); } - } const GridEditModalFc = React.forwardRef((props, ref) => { diff --git a/apps/app/src/client/components/PageEditor/GridEditorUtil.js b/apps/app/src/client/components/PageEditor/GridEditorUtil.js index dd744ccfcde..b126f7d786c 100644 --- a/apps/app/src/client/components/PageEditor/GridEditorUtil.js +++ b/apps/app/src/client/components/PageEditor/GridEditorUtil.js @@ -2,7 +2,6 @@ * Utility for grid editor */ class GridEditorUtil { - constructor() { // https://regex101.com/r/7BN2fR/11 this.lineBeginPartOfGridRE = /^:::(\s.*)editable-row$/; @@ -10,19 +9,42 @@ class GridEditorUtil { this.mappingAllGridDivisionPatterns = [ { numberOfGridDivisions: 2, - mapping: [[2, 10], [4, 8], [6, 6], [8, 4], [10, 2]], + mapping: [ + [2, 10], + [4, 8], + [6, 6], + [8, 4], + [10, 2], + ], }, { numberOfGridDivisions: 3, - mapping: [[2, 5, 5], [5, 2, 5], [5, 5, 2], [4, 4, 4], [3, 3, 6], [3, 6, 3], [6, 3, 3]], + mapping: [ + [2, 5, 5], + [5, 2, 5], + [5, 5, 2], + [4, 4, 4], + [3, 3, 6], + [3, 6, 3], + [6, 3, 3], + ], }, { numberOfGridDivisions: 4, - mapping: [[2, 2, 4, 4], [4, 4, 2, 2], [2, 4, 2, 4], [4, 2, 4, 2], [3, 3, 3, 3], [2, 2, 2, 6], [6, 2, 2, 2]], + mapping: [ + [2, 2, 4, 4], + [4, 4, 2, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + [3, 3, 3, 3], + [2, 2, 2, 6], + [6, 2, 2, 2], + ], }, ]; this.isInGridBlock = this.isInGridBlock.bind(this); - this.replaceGridWithHtmlWithEditor = this.replaceGridWithHtmlWithEditor.bind(this); + this.replaceGridWithHtmlWithEditor = + this.replaceGridWithHtmlWithEditor.bind(this); } /** @@ -34,7 +56,7 @@ class GridEditorUtil { if (bog === null || eog === null) { return false; } - return (JSON.stringify(bog) !== JSON.stringify(eog)); + return JSON.stringify(bog) !== JSON.stringify(eog); } /** @@ -98,7 +120,10 @@ class GridEditorUtil { const lastLine = editor.getDoc().lastLine(); if (this.lineEndPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) { - return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length }; + return { + line: curPos.line, + ch: editor.getDoc().getLine(curPos.line).length, + }; } let line = curPos.line + 1; @@ -147,7 +172,6 @@ class GridEditorUtil { }); return cols.join('\n'); } - } // singleton pattern diff --git a/apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx b/apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx index 1a5bc6e6aec..6a6081c096d 100644 --- a/apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx +++ b/apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx @@ -1,19 +1,35 @@ import React, { - useState, useCallback, useMemo, useEffect, type JSX, + type JSX, + useCallback, + useEffect, + useMemo, + useState, } from 'react'; - -import { MarkdownTable, useHandsontableModalForEditorStatus, useHandsontableModalForEditorActions } from '@growi/editor'; +import { + MarkdownTable, + useHandsontableModalForEditorActions, + useHandsontableModalForEditorStatus, +} from '@growi/editor'; import { HotTable } from '@handsontable/react'; import type Handsontable from 'handsontable'; import { useTranslation } from 'next-i18next'; import { Collapse, - Modal, ModalHeader, ModalBody, ModalFooter, + Modal, + ModalBody, + ModalFooter, + ModalHeader, } from 'reactstrap'; import { debounce } from 'throttle-debounce'; -import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/client/components/PageEditor/markdown-table-util-for-editor'; -import { useHandsontableModalActions, useHandsontableModalStatus } from '~/states/ui/modal/handsontable'; +import { + getMarkdownTable, + replaceFocusedMarkdownTableWithEditor, +} from '~/client/components/PageEditor/markdown-table-util-for-editor'; +import { + useHandsontableModalActions, + useHandsontableModalStatus, +} from '~/states/ui/modal/handsontable'; import ExpandOrContractButton from '../../ExpandOrContractButton'; import { MarkdownTableDataImportForm } from '../MarkdownTableDataImportForm'; @@ -42,7 +58,9 @@ type HandsontableModalSubstanceProps = { /** * HandsontableModalSubstance - Presentation component (heavy logic, rendered only when isOpen) */ -const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX.Element => { +const HandsontableModalSubstance = ( + props: HandsontableModalSubstanceProps, +): JSX.Element => { const { initialTable, autoFormatMarkdownTable, @@ -99,11 +117,16 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called. */ const [hotTable, setHotTable] = useState(); - const [hotTableContainer, setHotTableContainer] = useState(); - const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState(false); - const [markdownTable, setMarkdownTable] = useState(defaultMarkdownTable); - const [markdownTableOnInit, setMarkdownTableOnInit] = useState(defaultMarkdownTable); - const [handsontableHeight, setHandsontableHeight] = useState(DEFAULT_HOT_HEIGHT); + const [hotTableContainer, setHotTableContainer] = + useState(); + const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = + useState(false); + const [markdownTable, setMarkdownTable] = + useState(defaultMarkdownTable); + const [markdownTableOnInit, setMarkdownTableOnInit] = + useState(defaultMarkdownTable); + const [handsontableHeight, setHandsontableHeight] = + useState(DEFAULT_HOT_HEIGHT); const [handsontableWidth, setHandsontableWidth] = useState(0); // Memoize window resize handler @@ -117,13 +140,15 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX }, [hotTableContainer]); // Memoize debounced handler - const debouncedHandleWindowExpandedChange = useMemo(() => ( - debounce(100, handleWindowExpandedChange) - ), [handleWindowExpandedChange]); + const debouncedHandleWindowExpandedChange = useMemo( + () => debounce(100, handleWindowExpandedChange), + [handleWindowExpandedChange], + ); // Initialize table data when component mounts (modal opens) useEffect(() => { - const initTableInstance = initialTable == null ? defaultMarkdownTable : initialTable.clone(); + const initTableInstance = + initialTable == null ? defaultMarkdownTable : initialTable.clone(); setMarkdownTable(initialTable ?? defaultMarkdownTable); setMarkdownTableOnInit(initTableInstance); debouncedHandleWindowExpandedChange(); @@ -172,7 +197,13 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX onSave(newMarkdownTable); cancel(); - }, [hotTable, markdownTable.options.align, autoFormatMarkdownTable, onSave, cancel]); + }, [ + hotTable, + markdownTable.options.align, + autoFormatMarkdownTable, + onSave, + cancel, + ]); const beforeColumnResizeHandler = (currentColumn) => { /* @@ -181,7 +212,6 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX * * At the moment, using 'afterColumnResizeHandler' instead. */ - // store column index // this.manuallyResizedColumnIndicesSet.add(currentColumn); }; @@ -236,7 +266,12 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX for (let i = 0; i < align.length; i++) { for (let j = 0; j < hotInstance.countRows(); j++) { - hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]); + hotInstance.setCellMeta( + j, + i, + 'className', + MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]], + ); } } hotInstance.render(); @@ -282,49 +317,48 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX const removed = align.splice(columns[0], columns.length); /* - * The following is a description of the algorithm for the alignment synchronization. - * - * Consider the case where the target is X and the columns are [2,3] and data is as follows. - * - * 0 1 2 3 4 5 (insert position number) - * +-+-+-+-+-+ - * | | | | | | - * +-+-+-+-+-+ - * 0 1 2 3 4 (column index number) - * - * At first, remove columns by the splice. - * - * 0 1 2 4 5 - * +-+-+ +-+ - * | | | | | - * +-+-+ +-+ - * 0 1 4 - * - * Next, insert those columns into a new position. - * However the target number is a insert position number before deletion, it may be changed. - * These are changed as follows. - * - * Before: - * 0 1 2 4 5 - * +-+-+ +-+ - * | | | | | - * +-+-+ +-+ - * - * After: - * 0 1 2 2 3 - * +-+-+ +-+ - * | | | | | - * +-+-+ +-+ - * - * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed. - * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length. - * - */ + * The following is a description of the algorithm for the alignment synchronization. + * + * Consider the case where the target is X and the columns are [2,3] and data is as follows. + * + * 0 1 2 3 4 5 (insert position number) + * +-+-+-+-+-+ + * | | | | | | + * +-+-+-+-+-+ + * 0 1 2 3 4 (column index number) + * + * At first, remove columns by the splice. + * + * 0 1 2 4 5 + * +-+-+ +-+ + * | | | | | + * +-+-+ +-+ + * 0 1 4 + * + * Next, insert those columns into a new position. + * However the target number is a insert position number before deletion, it may be changed. + * These are changed as follows. + * + * Before: + * 0 1 2 4 5 + * +-+-+ +-+ + * | | | | | + * +-+-+ +-+ + * + * After: + * 0 1 2 2 3 + * +-+-+ +-+ + * | | | | | + * +-+-+ +-+ + * + * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed. + * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length. + * + */ let insertPosition = 0; if (target <= columns[0]) { insertPosition = target; - } - else if (columns[columns.length - 1] < target) { + } else if (columns[columns.length - 1] < target) { insertPosition = target - columns.length; } @@ -334,7 +368,9 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX setMarkdownTable((prevMarkdownTable) => { // change only align info, so share table data to avoid redundant copy - const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align }); + const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { + align, + }); return newMarkdownTable; }); @@ -347,7 +383,9 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX const align = (direction: string, startCol: number, endCol: number) => { setMarkdownTable((prevMarkdownTable) => { // change only align info, so share table data to avoid redundant copy - const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align: [].concat(prevMarkdownTable.options.align) }); + const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { + align: [].concat(prevMarkdownTable.options.align), + }); for (let i = startCol; i <= endCol; i++) { newMarkdownTable.options.align[i] = direction; } @@ -365,8 +403,14 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX const selectedRange = hotTable.hotInstance.getSelectedRange(); if (selectedRange == null) return; - const startCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].from.col : selectedRange[0].to.col; - const endCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].to.col : selectedRange[0].from.col; + const startCol = + selectedRange[0].from.col < selectedRange[0].to.col + ? selectedRange[0].from.col + : selectedRange[0].to.col; + const endCol = + selectedRange[0].from.col < selectedRange[0].to.col + ? selectedRange[0].to.col + : selectedRange[0].from.col; align(direction, startCol, endCol); }; @@ -413,15 +457,23 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX { name: 'Left', key: 'align_columns:1', - callback: (key, selection) => { align('l', selection[0].start.col, selection[0].end.col) }, - }, { + callback: (key, selection) => { + align('l', selection[0].start.col, selection[0].end.col); + }, + }, + { name: 'Center', key: 'align_columns:2', - callback: (key, selection) => { align('c', selection[0].start.col, selection[0].end.col) }, - }, { + callback: (key, selection) => { + align('c', selection[0].start.col, selection[0].end.col); + }, + }, + { name: 'Right', key: 'align_columns:3', - callback: (key, selection) => { align('r', selection[0].start.col, selection[0].end.col) }, + callback: (key, selection) => { + align('r', selection[0].start.col, selection[0].end.col); + }, }, ], }, @@ -442,7 +494,12 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX contractWindow={contractWindow} expandWindow={expandWindow} /> - + ); @@ -462,22 +519,51 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX onClick={toggleDataImportArea} > {t('handsontable_modal.data_import')} - {isDataImportAreaExpanded ? 'expand_less' : 'expand_more'} + + {isDataImportAreaExpanded ? 'expand_less' : 'expand_more'} + -
    - - - -
    +
    - +
    @@ -505,10 +591,24 @@ const HandsontableModalSubstance = (props: HandsontableModalSubstanceProps): JSX
    - +
    - - + +
    @@ -526,7 +626,8 @@ export const HandsontableModal = (): JSX.Element => { // for Editor const handsontableModalForEditorData = useHandsontableModalForEditorStatus(); - const { close: closeHandsontableModalForEditor } = useHandsontableModalForEditorActions(); + const { close: closeHandsontableModalForEditor } = + useHandsontableModalForEditorActions(); const isOpenedForView = handsontableModalData.isOpened; const isOpenedForEditor = handsontableModalForEditorData.isOpened; @@ -541,10 +642,15 @@ export const HandsontableModal = (): JSX.Element => { return editor != null ? getMarkdownTable(editor) : undefined; } return handsontableModalData.table; - }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData.table]); + }, [ + isOpenedForEditor, + handsontableModalForEditorData.editor, + handsontableModalData.table, + ]); // Determine autoFormatMarkdownTable based on mode - const autoFormatMarkdownTable = handsontableModalData.autoFormatMarkdownTable ?? false; + const autoFormatMarkdownTable = + handsontableModalData.autoFormatMarkdownTable ?? false; const toggle = useCallback(() => { closeHandsontableModal(); @@ -561,17 +667,23 @@ export const HandsontableModal = (): JSX.Element => { }, []); // Create save handler based on mode - const handleSave = useCallback((newMarkdownTable: MarkdownTable) => { - if (isOpenedForEditor) { - const editor = handsontableModalForEditorData.editor; - if (editor != null) { - replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable); + const handleSave = useCallback( + (newMarkdownTable: MarkdownTable) => { + if (isOpenedForEditor) { + const editor = handsontableModalForEditorData.editor; + if (editor != null) { + replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable); + } + } else { + handsontableModalData.onSave?.(newMarkdownTable); } - } - else { - handsontableModalData.onSave?.(newMarkdownTable); - } - }, [isOpenedForEditor, handsontableModalForEditorData.editor, handsontableModalData]); + }, + [ + isOpenedForEditor, + handsontableModalForEditorData.editor, + handsontableModalData, + ], + ); return ( { const HandsontableModal = useLazyLoader( 'handsontable-modal', - () => import('./HandsontableModal').then(mod => ({ default: mod.HandsontableModal })), + () => + import('./HandsontableModal').then((mod) => ({ + default: mod.HandsontableModal, + })), status?.isOpened || statusForEditor?.isOpened || false, ); diff --git a/apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx b/apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx index f6871809ea0..da9ba8ac173 100644 --- a/apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx +++ b/apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx @@ -1,17 +1,17 @@ -import React, { - useEffect, useState, useCallback, -} from 'react'; - -import path from 'path'; - +import type React from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Linker } from '@growi/editor/dist/models/linker'; -import { useLinkEditModalStatus, useLinkEditModalActions } from '@growi/editor/dist/states/modal/link-edit'; +import { + useLinkEditModalActions, + useLinkEditModalStatus, +} from '@growi/editor/dist/states/modal/link-edit'; import { useTranslation } from 'next-i18next'; +import path from 'path'; import { Modal, - ModalHeader, ModalBody, ModalFooter, + ModalHeader, Popover, PopoverBody, } from 'reactstrap'; @@ -25,10 +25,8 @@ import loggerFactory from '~/utils/logger'; import SearchTypeahead from '../../SearchTypeahead'; import Preview from '../Preview'; - import styles from './LinkEditPreview.module.scss'; - const logger = loggerFactory('growi:components:LinkEditModal'); /** @@ -52,11 +50,16 @@ const LinkEditModalSubstance: React.FC = () => { const [permalink, setPermalink] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(false); - const getRootPath = useCallback((type: string) => { - // rootPaths of md link and pukiwiki link are different - if (currentPath == null) return ''; - return type === Linker.types.markdownLink ? path.dirname(currentPath) : currentPath; - }, [currentPath]); + const getRootPath = useCallback( + (type: string) => { + // rootPaths of md link and pukiwiki link are different + if (currentPath == null) return ''; + return type === Linker.types.markdownLink + ? path.dirname(currentPath) + : currentPath; + }, + [currentPath], + ); // parse link, link is ... // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga') @@ -64,52 +67,61 @@ const LinkEditModalSubstance: React.FC = () => { // case-3. relative path of this growi's page (ex. '../fuga', 'hoge') // case-4. external link (ex. 'https://growi.org') // case-5. the others (ex. '') - const parseLinkAndSetState = useCallback((link: string, type: string) => { - // create url from link, add dummy origin if link is not valid url. - // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4) - // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5) - let isFqcn = false; - let isUseRelativePath = false; - let url; - try { - const url = new URL(link, 'http://example.com'); - isFqcn = url.origin !== 'http://example.com'; - } - catch (err) { - logger.debug(err); - } - - // case-1: when link is this growi's page url, return pathname only - let reshapedLink = url != null && url.origin === window.location.origin - ? decodeURIComponent(url.pathname) - : link; + const parseLinkAndSetState = useCallback( + (link: string, type: string) => { + // create url from link, add dummy origin if link is not valid url. + // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4) + // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5) + let isFqcn = false; + let isUseRelativePath = false; + let url: URL | undefined; + try { + url = new URL(link, 'http://example.com'); + isFqcn = url.origin !== 'http://example.com'; + } catch (err) { + logger.debug(err); + } - // case-3 - if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') { - isUseRelativePath = true; - const rootPath = getRootPath(type); - reshapedLink = path.resolve(rootPath, reshapedLink); - } + // case-1: when link is this growi's page url, return pathname only + let reshapedLink = + url != null && url.origin === window.location.origin + ? decodeURIComponent(url.pathname) + : link; + + // case-3 + if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') { + isUseRelativePath = true; + const rootPath = getRootPath(type); + reshapedLink = path.resolve(rootPath, reshapedLink); + } - setLinkInputValue(reshapedLink); - setIsUseRelativePath(isUseRelativePath); - }, [getRootPath]); + setLinkInputValue(reshapedLink); + setIsUseRelativePath(isUseRelativePath); + }, + [getRootPath], + ); useEffect(() => { - if (linkEditModalStatus == null) { return } - const { label = '', link = '' } = linkEditModalStatus.defaultMarkdownLink ?? {}; - const { type = Linker.types.markdownLink } = linkEditModalStatus.defaultMarkdownLink ?? {}; + if (linkEditModalStatus == null) { + return; + } + const { label = '', link = '' } = + linkEditModalStatus.defaultMarkdownLink ?? {}; + const { type = Linker.types.markdownLink } = + linkEditModalStatus.defaultMarkdownLink ?? {}; parseLinkAndSetState(link, type); setLabelInputValue(label); setIsUsePermanentLink(false); setPermalink(''); setLinkerType(type); - }, [linkEditModalStatus, parseLinkAndSetState]); const toggleIsUseRelativePath = useCallback(() => { - if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) { + if ( + !linkInputValue.startsWith('/') || + linkerType === Linker.types.growiLink + ) { return; } @@ -128,7 +140,7 @@ const LinkEditModalSubstance: React.FC = () => { setIsUseRelativePath(false); }, [permalink, linkerType, isUsePermanentLink]); - const setMarkdownHandler = useCallback(async() => { + const setMarkdownHandler = useCallback(async () => { const path = linkInputValue; let markdown = ''; let pagePath = ''; @@ -137,20 +149,23 @@ const LinkEditModalSubstance: React.FC = () => { if (path.startsWith('/')) { try { const pathWithoutFragment = new URL(path, 'http://dummy').pathname; - const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1)); + const isPermanentLink = validator.isMongoId( + pathWithoutFragment.slice(1), + ); const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null; - const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId }); + const { data } = await apiv3Get('/page', { + path: pathWithoutFragment, + page_id: pageId, + }); const { page } = data; markdown = page.revision.body; pagePath = page.path; permalink = page.id; - } - catch (err) { + } catch (err) { setPreviewError(err.message); } - } - else { + } else { setPreviewError(t('link_edit.page_not_found_in_preview', { path })); } @@ -160,11 +175,13 @@ const LinkEditModalSubstance: React.FC = () => { }, [linkInputValue, t]); const generateLink = useCallback(() => { - let reshapedLink = linkInputValue; if (isUseRelativePath) { const rootPath = getRootPath(linkerType); - reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue); + reshapedLink = + rootPath === linkInputValue + ? '.' + : path.relative(rootPath, linkInputValue); } if (isUsePermanentLink && permalink != null) { @@ -172,7 +189,15 @@ const LinkEditModalSubstance: React.FC = () => { } return new Linker(linkerType, labelInputValue, reshapedLink); - }, [linkInputValue, isUseRelativePath, getRootPath, linkerType, isUsePermanentLink, permalink, labelInputValue]); + }, [ + linkInputValue, + isUseRelativePath, + getRootPath, + linkerType, + isUsePermanentLink, + permalink, + labelInputValue, + ]); const renderLinkPreview = (): React.JSX.Element => { const linker = generateLink(); @@ -180,12 +205,18 @@ const LinkEditModalSubstance: React.FC = () => {

    Markdown

    -

    {linker.generateMarkdownText()}

    +

    + {linker.generateMarkdownText()} +

    - arrow_right - arrow_drop_down + + arrow_right + + + arrow_drop_down +
    @@ -212,16 +243,22 @@ const LinkEditModalSubstance: React.FC = () => { setLabelInputValue(label); }, []); - const handleChangeLinkInput = useCallback((link) => { - let useRelativePath = isUseRelativePath; - if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) { - useRelativePath = false; - } - setLinkInputValue(link); - setIsUseRelativePath(useRelativePath); - setIsUsePermanentLink(false); - setPermalink(''); - }, [linkInputValue, isUseRelativePath, linkerType]); + const handleChangeLinkInput = useCallback( + (link) => { + let useRelativePath = isUseRelativePath; + if ( + !linkInputValue.startsWith('/') || + linkerType === Linker.types.growiLink + ) { + useRelativePath = false; + } + setLinkInputValue(link); + setIsUseRelativePath(useRelativePath); + setIsUsePermanentLink(false); + setPermalink(''); + }, + [linkInputValue, isUseRelativePath, linkerType], + ); const save = useCallback(() => { const linker = generateLink(); @@ -233,7 +270,7 @@ const LinkEditModalSubstance: React.FC = () => { close(); }, [generateLink, linkEditModalStatus, close]); - const toggleIsPreviewOpen = useCallback(async() => { + const toggleIsPreviewOpen = useCallback(async () => { // open popover if (!isPreviewOpen) { setMarkdownHandler(); @@ -259,18 +296,36 @@ const LinkEditModalSubstance: React.FC = () => { autoFocus />
    - - + - {markdown != null && pagePath != null && rendererOptions != null - && ( -
    - -
    - ) - } + {markdown != null && + pagePath != null && + rendererOptions != null && ( +
    + +
    + )}
    @@ -286,7 +341,7 @@ const LinkEditModalSubstance: React.FC = () => { className="form-control" id="label" value={labelInputValue} - onChange={e => handleChangeLabelInput(e.target.value)} + onChange={(e) => handleChangeLabelInput(e.target.value)} disabled={linkerType === Linker.types.growiLink} placeholder={linkInputValue} /> @@ -302,7 +357,9 @@ const LinkEditModalSubstance: React.FC = () => {
    - + + {t('link_edit.path_format')} +
    { type="checkbox" checked={isUseRelativePath} onChange={toggleIsUseRelativePath} - disabled={!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink} + disabled={ + !linkInputValue.startsWith('/') || + linkerType === Linker.types.growiLink + } /> -
    @@ -324,9 +387,14 @@ const LinkEditModalSubstance: React.FC = () => { type="checkbox" checked={isUsePermanentLink} onChange={toggleIsUsePamanentLink} - disabled={permalink === '' || linkerType === Linker.types.growiLink} + disabled={ + permalink === '' || linkerType === Linker.types.growiLink + } /> -
    @@ -358,11 +426,19 @@ const LinkEditModalSubstance: React.FC = () => {
    - { previewError && {previewError}} - - @@ -380,7 +456,13 @@ export const LinkEditModal = (): React.JSX.Element => { const isOpened = linkEditModalStatus?.isOpened ?? false; return ( - + {isOpened && } ); diff --git a/apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx b/apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx index 3613e4f926f..106543b3404 100644 --- a/apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx +++ b/apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx @@ -1,5 +1,4 @@ import type { JSX } from 'react'; - import { useLinkEditModalStatus } from '@growi/editor/dist/states/modal/link-edit'; import { useLazyLoader } from '~/components/utils/use-lazy-loader'; @@ -11,7 +10,8 @@ export const LinkEditModalLazyLoaded = (): JSX.Element => { const LinkEditModal = useLazyLoader( 'link-edit-modal', - () => import('./LinkEditModal').then(mod => ({ default: mod.LinkEditModal })), + () => + import('./LinkEditModal').then((mod) => ({ default: mod.LinkEditModal })), status?.isOpened ?? false, ); diff --git a/apps/app/src/client/components/PageEditor/MarkdownListUtil.js b/apps/app/src/client/components/PageEditor/MarkdownListUtil.js index aea9ccff9a4..dbdc40dad8b 100644 --- a/apps/app/src/client/components/PageEditor/MarkdownListUtil.js +++ b/apps/app/src/client/components/PageEditor/MarkdownListUtil.js @@ -2,14 +2,16 @@ * Utility for markdown list */ class MarkdownListUtil { - constructor() { // https://github.com/codemirror/CodeMirror/blob/c7853a989c77bb9f520c9c530cbe1497856e96fc/addon/edit/continuelist.js#L14 // https://regex101.com/r/7BN2fR/5 - this.indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/; - this.indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/; + this.indentAndMarkRE = + /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/; + this.indentAndMarkOnlyRE = + /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/; - this.newlineAndIndentContinueMarkdownList = this.newlineAndIndentContinueMarkdownList.bind(this); + this.newlineAndIndentContinueMarkdownList = + this.newlineAndIndentContinueMarkdownList.bind(this); this.pasteText = this.pasteText.bind(this); } @@ -23,13 +25,11 @@ class MarkdownListUtil { if (this.indentAndMarkOnlyRE.test(strFromBol)) { // clear current line and end list editor.replaceBolToCurrentPos('\n'); - } - else if (this.indentAndMarkRE.test(strFromBol)) { + } else if (this.indentAndMarkRE.test(strFromBol)) { // continue list const indentAndMark = strFromBol.match(this.indentAndMarkRE)[0]; editor.insertText(`\n${indentAndMark}`); - } - else { + } else { editor.insertLinebreak(); } } @@ -124,7 +124,6 @@ class MarkdownListUtil { return isListful; } - } // singleton pattern diff --git a/apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx b/apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx index 11f29d53868..727e8c78a37 100644 --- a/apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx +++ b/apps/app/src/client/components/PageEditor/MarkdownTableDataImportForm.tsx @@ -1,30 +1,28 @@ -import React, { useState, type JSX } from 'react'; - +import React, { type JSX, useState } from 'react'; import { MarkdownTable } from '@growi/editor'; import { useTranslation } from 'next-i18next'; -import { - Button, - Collapse, -} from 'reactstrap'; - +import { Button, Collapse } from 'reactstrap'; type MarkdownTableDataImportFormProps = { - onCancel: () => void, - onImport: (table: MarkdownTable) => void, -} - -export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormProps): JSX.Element => { + onCancel: () => void; + onImport: (table: MarkdownTable) => void; +}; +export const MarkdownTableDataImportForm = ( + props: MarkdownTableDataImportFormProps, +): JSX.Element => { const { onCancel, onImport } = props; - const { t } = useTranslation('commons', { keyPrefix: 'handsontable_modal.data_import_form' }); + const { t } = useTranslation('commons', { + keyPrefix: 'handsontable_modal.data_import_form', + }); const [dataFormat, setDataFormat] = useState('csv'); const [data, setData] = useState(''); const [parserErrorMessage, setParserErrorMessage] = useState(null); const convertFormDataToMarkdownTable = () => { - let result; + let result: MarkdownTable; switch (dataFormat) { case 'csv': result = MarkdownTable.fromDSV(data, ','); @@ -35,6 +33,8 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr case 'html': result = MarkdownTable.fromHTMLTableTag(data); break; + default: + throw new Error(`Unsupported format: ${dataFormat}`); } return result.normalizeCells(); }; @@ -44,8 +44,7 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr const markdownTable = convertFormDataToMarkdownTable(); onImport(markdownTable); setParserErrorMessage(null); - } - catch (e) { + } catch (e) { setParserErrorMessage(e.message); } }; @@ -53,12 +52,16 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr return (
    - +
    - +