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

Merge remote-tracking branch 'origin/dev/7.4.x' into support/156162-175866-app-client-services-biome

Yuki Takei 3 месяцев назад
Родитель
Сommit
6c11140ccb

+ 1 - 0
apps/app/.eslintrc.js

@@ -89,6 +89,7 @@ module.exports = {
     'src/client/interfaces/**',
     'src/client/models/**',
     'src/client/services/**',
+    'src/client/util/**'
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 15 - 21
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -292,8 +292,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const [isStickyActive, setStickyActive] = useState(false);
 
   const path = currentPage?.path ?? currentPathname;
-  // const grant = currentPage?.grant ?? grantData?.grant;
-  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
@@ -404,31 +402,27 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             id="grw-contextual-sub-nav"
           >
 
-            {pageId != null && (
-              <PageControls
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path ?? currentPathname} // If the page is empty, "path" is undefined
-                expandContentWidth={shouldExpandContent}
-                disableSeenUserInfoPopover={isSharedUser}
-                hideSubControls={hideSubControls}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-                onClickSwitchContentWidth={switchContentWidthHandler}
-              />
-            )}
+            <PageControls
+              pageId={pageId}
+              revisionId={revisionId}
+              shareLinkId={shareLinkId}
+              path={path ?? currentPathname} // If the page is empty, "path" is undefined
+              expandContentWidth={shouldExpandContent}
+              disableSeenUserInfoPopover={isSharedUser}
+              hideSubControls={hideSubControls}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={additionalMenuItemsRenderer}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+              onClickSwitchContentWidth={switchContentWidthHandler}
+            />
 
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 editorMode={editorMode}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 path={path}
-              // grant={grant}
-              // grantUserGroupId={grantUserGroupId}
               />
             )}
 

+ 29 - 27
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,8 +3,7 @@ import React, {
 } from 'react';
 
 import type {
-  IPageInfoForEmpty,
-  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 import {
   isIPageInfoForEmpty,
@@ -107,7 +106,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
-  pageId: string,
+  pageId?: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
   path?: string | null,
@@ -124,7 +123,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation | IPageInfoForEmpty,
+  pageInfo: IPageInfo | undefined,
   onClickEditTagsButton: () => void,
 }
 
@@ -170,10 +169,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
   const subscribeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot subscribe to pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -182,10 +183,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot like pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -194,7 +197,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDuplicateMenuItem == null || path == null) {
+    if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -203,7 +207,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickRenameMenuItem == null || path == null) {
+    if (onClickRenameMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
       return;
     }
 
@@ -220,7 +225,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDeleteMenuItem == null || path == null) {
+    if (onClickDeleteMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
       return;
     }
 
@@ -237,22 +243,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(() => {
-    if (onClickSwitchContentWidth == null) {
+    if (isGuestUser || isReadOnlyUser) {
+      logger.warn('Guest or read-only users cannot switch content width');
       return;
     }
 
-    const newValue = !expandContentWidth;
-    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
-      logger.warn('Could not switch content width', {
-        isGuestUser,
-        isReadOnlyUser,
-      });
+    if (onClickSwitchContentWidth == null || pageId == null) {
+      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
+      logger.warn('PageInfo is not for entity');
       return;
     }
+
     try {
+      const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
@@ -315,13 +321,13 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {isIPageInfoForEntity(pageInfo) && revisionId != null && (
+          {isIPageInfoForOperation(pageInfo) && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
             />
           )}
-          {isIPageInfoForEntity(pageInfo) && revisionId != null && (
+          {isIPageInfoForOperation(pageInfo) && (
             <LikeButtons
               onLikeClicked={likeClickhandler}
               sumOfLikers={pageInfo.sumOfLikers}
@@ -329,14 +335,14 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               likers={likers}
             />
           )}
-          {revisionId != null && (
+          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
             <BookmarkButtons
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {isIPageInfoForEntity(pageInfo) && revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
               sumOfSeenUsers={pageInfo.sumOfSeenUsers}
@@ -377,7 +383,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const { open: openTagEditModal } = useTagEditModalActions();
 
   const onClickEditTagsButton = useCallback(() => {
-    if (tagsInfoData == null || revisionId == null) {
+    if (tagsInfoData == null || pageId == null || revisionId == null) {
       return;
     }
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
@@ -387,10 +393,6 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
   }
 
-  if (!isIPageInfoForOperation(pageInfo) && !isIPageInfoForEmpty(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <PageControlsSubstance
       pageInfo={pageInfo}

+ 26 - 9
apps/app/src/client/util/apiv1-client.ts

@@ -5,7 +5,6 @@ import axios from '~/utils/axios';
 const apiv1Root = '/_api';
 
 class Apiv1ErrorHandler extends Error {
-
   code;
 
   data;
@@ -16,12 +15,14 @@ class Apiv1ErrorHandler extends Error {
     this.message = message;
     this.code = code;
     this.data = data;
-
   }
-
 }
 
-export async function apiRequest<T>(method: string, path: string, params: unknown): Promise<T> {
+export async function apiRequest<T>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<T> {
   const res = await axios[method](urljoin(apiv1Root, path), params);
 
   if (res.data.ok) {
@@ -30,25 +31,41 @@ export async function apiRequest<T>(method: string, path: string, params: unknow
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
+    const error = new Apiv1ErrorHandler(
+      res.data.error,
+      res.data.code,
+      res.data.data,
+    );
     throw error;
   }
 
   throw new Error(res.data.error);
 }
 
-export async function apiGet<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiGet<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('get', path, { params });
 }
 
-export async function apiPost<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiPost<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('post', path, params);
 }
 
-export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
+export async function apiPostForm<T>(
+  path: string,
+  formData: FormData,
+): Promise<T> {
   return apiRequest<T>('postForm', path, formData);
 }
 
-export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiDelete<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('delete', path, { data: params });
 }

+ 29 - 10
apps/app/src/client/util/apiv3-client.ts

@@ -10,12 +10,13 @@ const apiv3Root = '/_api/v3';
 
 const logger = loggerFactory('growi:apiv3');
 
-
 const apiv3ErrorHandler = (_err: any): any[] => {
   // extract api errors from general 400 err
   const err = axios.isAxiosError(_err) ? _err.response?.data.errors : _err;
   const errs = toArrayIfNot(err);
-  const errorInfo = axios.isAxiosError(_err) ? _err.response?.data.info : undefined;
+  const errorInfo = axios.isAxiosError(_err)
+    ? _err.response?.data.info
+    : undefined;
 
   for (const err of errs) {
     logger.error(err.message);
@@ -28,33 +29,51 @@ const apiv3ErrorHandler = (_err: any): any[] => {
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
+export async function apiv3Request<T = any>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<AxiosResponse<T>> {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     return res;
-  }
-  catch (err) {
+  } catch (err) {
     const errors = apiv3ErrorHandler(err);
     throw errors;
   }
 }
 
-export async function apiv3Get<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Get<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('get', path, { params });
 }
 
-export async function apiv3Post<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Post<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('post', path, params);
 }
 
-export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+export async function apiv3PostForm<T = any>(
+  path: string,
+  formData: FormData,
+): Promise<AxiosResponse<T>> {
   return apiv3Request('postForm', path, formData);
 }
 
-export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Put<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('put', path, params);
 }
 
-export async function apiv3Delete<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Delete<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('delete', path, { params });
 }

+ 48 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,44 +1,80 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkedPage,
+  BookmarkFolderItems,
+} from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
 // Check if bookmark folder item has childFolder or bookmarks
-export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
-  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
+export const hasChildren = ({
+  childFolder,
+  bookmarks,
+}: {
+  childFolder?: BookmarkFolderItems[];
+  bookmarks?: BookmarkedPage[];
+}): boolean => {
+  return !!(
+    (childFolder && childFolder.length > 0) ||
+    (bookmarks && bookmarks.length > 0)
+  );
 };
 
 // Add new folder helper
-export const addNewFolder = async(name: string, parent: string | null): Promise<void> => {
+export const addNewFolder = async (
+  name: string,
+  parent: string | null,
+): Promise<void> => {
   await apiv3Post('/bookmark-folder', { name, parent });
 };
 
 // Put bookmark to a folder
-export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
+export const addBookmarkToFolder = async (
+  pageId: string,
+  folderId: string | null,
+): Promise<void> => {
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', {
+    pageId,
+    folderId,
+  });
 };
 
 // Delete bookmark folder
-export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<void> => {
+export const deleteBookmarkFolder = async (
+  bookmarkFolderId: string,
+): Promise<void> => {
   await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
 };
 
 // Rename page from bookmark item control
-export const renamePage = async(pageId: string, revisionId: Ref<IRevision> | undefined, newPagePath: string): Promise<void> => {
+export const renamePage = async (
+  pageId: string,
+  revisionId: Ref<IRevision> | undefined,
+  newPagePath: string,
+): Promise<void> => {
   await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
 };
 
 // Update bookmark by isBookmarked status
-export const toggleBookmark = async(pageId: string, status: boolean): Promise<void> => {
+export const toggleBookmark = async (
+  pageId: string,
+  status: boolean,
+): Promise<void> => {
   await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(
-    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+export const updateBookmarkFolder = async (
+  bookmarkFolderId: string,
+  name: string,
+  parent: string | null,
+  childFolder: BookmarkFolderItems[],
 ): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, childFolder,
+    bookmarkFolderId,
+    name,
+    parent,
+    childFolder,
   });
 };

+ 14 - 7
apps/app/src/client/util/scope-util.test.ts

@@ -1,10 +1,9 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
-import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+import { extractScopes, getDisabledScopes, parseScopes } from './scope-util';
 
 describe('scope-util', () => {
-
   const mockScopes = {
     READ: {
       USER: 'read:user',
@@ -45,8 +44,12 @@ describe('scope-util', () => {
     expect(result.ALL).toBeDefined();
 
     // Check admin settings
-    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
-    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe(
+      'read:admin:setting',
+    );
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe(
+      'write:admin:setting',
+    );
 
     // Check ALL category
     expect(result.ALL['read:all']).toBe('read:all');
@@ -79,8 +82,12 @@ describe('scope-util', () => {
   it('should handle multiple wildcard selections', () => {
     const selectedScopes = [SCOPE.READ.ALL, SCOPE.WRITE.ALL];
     const availableScopes = [
-      SCOPE.READ.FEATURES.PAGE, SCOPE.READ.FEATURES.ATTACHMENT, SCOPE.READ.ALL,
-      SCOPE.WRITE.FEATURES.PAGE, SCOPE.WRITE.FEATURES.ATTACHMENT, SCOPE.WRITE.ALL,
+      SCOPE.READ.FEATURES.PAGE,
+      SCOPE.READ.FEATURES.ATTACHMENT,
+      SCOPE.READ.ALL,
+      SCOPE.WRITE.FEATURES.PAGE,
+      SCOPE.WRITE.FEATURES.ATTACHMENT,
+      SCOPE.WRITE.ALL,
     ];
 
     const result = getDisabledScopes(selectedScopes, availableScopes);

+ 26 - 16
apps/app/src/client/util/scope-util.ts

@@ -1,6 +1,5 @@
 import { ALL_SIGN, type Scope } from '@growi/core/dist/interfaces';
 
-
 // Data structure for the final merged scopes
 interface ScopeMap {
   [key: string]: Scope | ScopeMap;
@@ -9,17 +8,17 @@ interface ScopeMap {
 // Input object with arbitrary action keys (e.g., READ, WRITE)
 type ScopesInput = Record<string, any>;
 
-
 function parseSubScope(
-    parentKey: string,
-    subObjForActions: Record<string, any>,
-    actions: string[],
+  parentKey: string,
+  subObjForActions: Record<string, any>,
+  actions: string[],
 ): ScopeMap {
   const result: ScopeMap = {};
 
   for (const action of actions) {
     if (typeof subObjForActions[action] === 'string') {
-      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] =
+        subObjForActions[action];
       subObjForActions[action] = undefined;
     }
   }
@@ -28,7 +27,9 @@ function parseSubScope(
   for (const action of actions) {
     const obj = subObjForActions[action];
     if (obj && typeof obj === 'object') {
-      Object.keys(obj).forEach(k => childKeys.add(k));
+      Object.keys(obj).forEach((k) => {
+        childKeys.add(k);
+      });
     }
   }
 
@@ -37,7 +38,8 @@ function parseSubScope(
       for (const action of actions) {
         const val = subObjForActions[action]?.[ck];
         if (typeof val === 'string') {
-          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] =
+            val as Scope;
         }
       }
       continue;
@@ -55,13 +57,21 @@ function parseSubScope(
   return result;
 }
 
-export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+export function parseScopes({
+  scopes,
+  isAdmin = false,
+}: {
+  scopes: ScopesInput;
+  isAdmin?: boolean;
+}): ScopeMap {
   const actions = Object.keys(scopes);
   const topKeys = new Set<string>();
 
   // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
   for (const action of actions) {
-    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+    Object.keys(scopes[action] || {}).forEach((k) => {
+      topKeys.add(k);
+    });
   }
 
   const result: ScopeMap = {};
@@ -81,8 +91,7 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
         }
       }
       result.ALL = allObj;
-    }
-    else {
+    } else {
       const subObjForActions: Record<string, any> = {};
       for (const action of actions) {
         subObjForActions[action] = scopes[action]?.[key];
@@ -97,10 +106,12 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
 /**
  * Determines which scopes should be disabled based on wildcard selections
  */
-export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+export function getDisabledScopes(
+  selectedScopes: Scope[],
+  availableScopes: string[],
+): Set<Scope> {
   const disabledSet = new Set<Scope>();
 
-
   // If no selected scopes, return empty set
   if (!selectedScopes || selectedScopes.length === 0) {
     return disabledSet;
@@ -133,8 +144,7 @@ export function extractScopes(obj: Record<string, any>): string[] {
   Object.values(obj).forEach((value) => {
     if (typeof value === 'string') {
       result.push(value);
-    }
-    else if (typeof value === 'object' && !Array.isArray(value)) {
+    } else if (typeof value === 'object' && !Array.isArray(value)) {
       result = result.concat(extractScopes(value));
     }
   });

+ 10 - 9
apps/app/src/client/util/t-with-opt.ts

@@ -1,15 +1,16 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
-export const useTWithOpt = (): (key: string, opt?: any) => string => {
-
+export const useTWithOpt = (): ((key: string, opt?: any) => string) => {
   const { t } = useTranslation();
 
-  return useCallback((key, opt) => {
-    if (typeof opt === 'object') {
-      return t(key, opt).toString();
-    }
-    return t(key);
-  }, [t]);
+  return useCallback(
+    (key, opt) => {
+      if (typeof opt === 'object') {
+        return t(key, opt).toString();
+      }
+      return t(key);
+    },
+    [t],
+  );
 };

+ 13 - 5
apps/app/src/client/util/toastr.ts

@@ -3,12 +3,14 @@ import { toast } from 'react-toastify';
 
 import { toArrayIfNot } from '~/utils/array-utils';
 
-
 export const toastErrorOption: ToastOptions = {
   autoClose: false,
   closeButton: true,
 };
-export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {
+export const toastError = (
+  err: string | Error | Error[],
+  option: ToastOptions = toastErrorOption,
+): void => {
   const errs = toArrayIfNot(err);
 
   if (errs.length === 0) {
@@ -16,7 +18,7 @@ export const toastError = (err: string | Error | Error[], option: ToastOptions =
   }
 
   for (const err of errs) {
-    const message = (typeof err === 'string') ? err : err.message;
+    const message = typeof err === 'string' ? err : err.message;
     toast.error(message, option);
   }
 };
@@ -25,7 +27,10 @@ export const toastSuccessOption: ToastOptions = {
   autoClose: 2000,
   closeButton: true,
 };
-export const toastSuccess = (content: ToastContent, option: ToastOptions = toastSuccessOption): void => {
+export const toastSuccess = (
+  content: ToastContent,
+  option: ToastOptions = toastSuccessOption,
+): void => {
   toast.success(content, option);
 };
 
@@ -33,6 +38,9 @@ export const toastWarningOption: ToastOptions = {
   autoClose: 5000,
   closeButton: true,
 };
-export const toastWarning = (content: ToastContent, option: ToastOptions = toastWarningOption): void => {
+export const toastWarning = (
+  content: ToastContent,
+  option: ToastOptions = toastWarningOption,
+): void => {
   toast.warning(content, option);
 };

+ 36 - 30
apps/app/src/client/util/use-input-validator.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const AlertType = {
@@ -7,7 +6,7 @@ export const AlertType = {
   ERROR: 'Error',
 } as const;
 
-export type AlertType = typeof AlertType[keyof typeof AlertType];
+export type AlertType = (typeof AlertType)[keyof typeof AlertType];
 
 export const ValidationTarget = {
   FOLDER: 'folder_name',
@@ -15,42 +14,49 @@ export const ValidationTarget = {
   DEFAULT: 'field',
 };
 
-export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+export type ValidationTarget =
+  (typeof ValidationTarget)[keyof typeof ValidationTarget];
 
 export type AlertInfo = {
-  type?: AlertType
-  message?: string,
-  target?: string
-}
-
+  type?: AlertType;
+  message?: string;
+  target?: string;
+};
 
 export type InputValidationResult = {
-  type: AlertType
-  typeLabel: string,
-  message: string,
-  target: string
-}
+  type: AlertType;
+  typeLabel: string;
+  message: string;
+  target: string;
+};
 
-export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void;
+export type InputValidator = (
+  input?: string,
+  alertType?: AlertType,
+) => InputValidationResult | void;
 
-export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => {
+export const useInputValidator = (
+  validationTarget: ValidationTarget = ValidationTarget.DEFAULT,
+): InputValidator => {
   const { t } = useTranslation();
 
-  const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => {
-    if ((input ?? '').trim() === '') {
-      return {
-        target: validationTarget,
-        type: alertType,
-        typeLabel: t(alertType),
-        message: t(
-          'input_validation.message.field_required',
-          { target: t(`input_validation.target.${validationTarget}`) },
-        ),
-      };
-    }
-
-    return;
-  }, [t, validationTarget]);
+  const inputValidator: InputValidator = useCallback(
+    (input?, alertType = AlertType.WARNING) => {
+      if ((input ?? '').trim() === '') {
+        return {
+          target: validationTarget,
+          type: alertType,
+          typeLabel: t(alertType),
+          message: t('input_validation.message.field_required', {
+            target: t(`input_validation.target.${validationTarget}`),
+          }),
+        };
+      }
+
+      return;
+    },
+    [t, validationTarget],
+  );
 
   return inputValidator;
 };

+ 11 - 11
apps/app/src/states/page/hooks.ts

@@ -121,24 +121,24 @@ export const useIsEditable = () => {
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isNotCreatable = useIsNotCreatable();
-
-  const getCombinedConditions = useAtomCallback(
-    useCallback((get) => {
-      const isForbidden = get(isForbiddenAtom);
-      const isIdenticalPath = get(isIdenticalPathAtom);
-
-      return !isForbidden && !isIdenticalPath;
-    }, []),
-  );
+  const isForbidden = useAtomValue(isForbiddenAtom);
+  const isIdenticalPath = useAtomValue(isIdenticalPathAtom);
 
   return useMemo(() => {
     return (
       !isGuestUser &&
       !isReadOnlyUser &&
       !isNotCreatable &&
-      getCombinedConditions()
+      !isForbidden &&
+      !isIdenticalPath
     );
-  }, [getCombinedConditions, isGuestUser, isReadOnlyUser, isNotCreatable]);
+  }, [
+    isGuestUser,
+    isReadOnlyUser,
+    isNotCreatable,
+    isForbidden,
+    isIdenticalPath,
+  ]);
 };
 
 /**

+ 0 - 2
apps/app/src/states/page/use-fetch-current-page.ts

@@ -241,8 +241,6 @@ export const useFetchCurrentPage = (): {
           const { data } = await apiv3Get<FetchedPageResult>('/page', params);
           const { page: newData, meta } = data;
 
-          console.log('Fetched page data:', { newData, meta });
-
           set(currentPageDataAtom, newData ?? undefined);
           set(currentPageEntityIdAtom, newData?._id);
           set(

+ 1 - 6
apps/app/src/states/ui/page-abilities.ts

@@ -110,12 +110,7 @@ export const useIsAbleToChangeEditorMode = (): boolean => {
   const isEditable = useIsEditable();
   const isSharedUser = useIsSharedUser();
 
-  const includesUndefined = [isEditable, isSharedUser].some(
-    (v) => v === undefined,
-  );
-  if (includesUndefined) return false;
-
-  return !!isEditable && !isSharedUser;
+  return isEditable && !isSharedUser;
 };
 
 /**

+ 1 - 2
biome.json

@@ -28,8 +28,7 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/src/client/components",
-      "!apps/app/src/client/util"
+      "!apps/app/src/client/components"
     ]
   },
   "formatter": {