From 4cbf4b74d636cbd0679a2c2ec8d88d9437cd906c Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 4 Nov 2024 05:28:05 +0000 Subject: [PATCH] feat(mobile): explorer create/rename operation (#8628) close AF-1560 --- .../member-components/invite-modal.tsx | 2 +- .../component/src/ui/button/button.tsx | 1 + .../component/src/ui/input/row-input.tsx | 1 + .../component/src/ui/menu/desktop/sub.tsx | 2 +- .../frontend/component/src/ui/menu/index.ts | 1 + .../component/src/ui/menu/menu.types.ts | 4 +- .../component/src/ui/menu/mobile/context.ts | 5 + .../component/src/ui/menu/mobile/hook.ts | 18 ++ .../component/src/ui/menu/mobile/root.tsx | 4 +- .../component/src/ui/menu/mobile/sub.tsx | 16 +- .../delete-leave-workspace/delete/index.tsx | 2 +- .../page-list/operation-menu-items/export.tsx | 2 +- .../operation-menu-items/snapshot.tsx | 2 +- .../explorer/nodes/collection/dialog.tsx | 43 ++++ .../explorer/nodes/collection/index.tsx | 21 +- .../explorer/nodes/collection/operations.tsx | 33 +++ .../components/explorer/nodes/doc/dialog.tsx | 18 ++ .../components/explorer/nodes/doc/index.tsx | 12 -- .../explorer/nodes/doc/operations.tsx | 26 +++ .../explorer/nodes/folder/dialog.tsx | 58 +++++ .../explorer/nodes/folder/index.tsx | 82 +++++--- .../explorer/nodes/tag/dialog.css.ts | 53 +++++ .../components/explorer/nodes/tag/dialog.tsx | 198 ++++++++++++++++++ .../components/explorer/nodes/tag/index.tsx | 21 +- .../explorer/nodes/tag/operations.tsx | 67 ++++-- .../explorer/sections/collections/index.tsx | 49 ++--- .../explorer/sections/organize/index.tsx | 46 ++-- .../explorer/sections/tags/index.tsx | 54 ++--- .../mobile/components/explorer/tree/node.tsx | 70 +------ .../core/src/mobile/components/index.ts | 1 + .../mobile/components/rename/content.css.ts | 35 ++++ .../src/mobile/components/rename/content.tsx | 67 ++++++ .../mobile/components/rename/dialog.css.ts | 17 ++ .../src/mobile/components/rename/dialog.tsx | 49 +++++ .../src/mobile/components/rename/index.tsx | 4 + .../src/mobile/components/rename/sub-menu.tsx | 53 +++++ .../core/src/mobile/components/rename/type.ts | 38 ++++ .../modules/create-workspace/views/dialog.tsx | 2 +- .../src/modules/explorer/views/tree/node.tsx | 7 +- .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/resources/en.json | 19 +- .../affine-mobile/e2e/explorer-folder.spec.ts | 76 +++++++ tests/affine-mobile/e2e/explorer-tag.spec.ts | 87 ++++++++ tests/affine-mobile/e2e/utils.ts | 23 +- 44 files changed, 1139 insertions(+), 252 deletions(-) create mode 100644 packages/frontend/component/src/ui/menu/mobile/hook.ts create mode 100644 packages/frontend/core/src/mobile/components/explorer/nodes/collection/dialog.tsx create mode 100644 packages/frontend/core/src/mobile/components/explorer/nodes/doc/dialog.tsx create mode 100644 packages/frontend/core/src/mobile/components/explorer/nodes/folder/dialog.tsx create mode 100644 packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.css.ts create mode 100644 packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.tsx create mode 100644 packages/frontend/core/src/mobile/components/rename/content.css.ts create mode 100644 packages/frontend/core/src/mobile/components/rename/content.tsx create mode 100644 packages/frontend/core/src/mobile/components/rename/dialog.css.ts create mode 100644 packages/frontend/core/src/mobile/components/rename/dialog.tsx create mode 100644 packages/frontend/core/src/mobile/components/rename/index.tsx create mode 100644 packages/frontend/core/src/mobile/components/rename/sub-menu.tsx create mode 100644 packages/frontend/core/src/mobile/components/rename/type.ts create mode 100644 tests/affine-mobile/e2e/explorer-folder.spec.ts create mode 100644 tests/affine-mobile/e2e/explorer-tag.spec.ts diff --git a/packages/frontend/component/src/components/member-components/invite-modal.tsx b/packages/frontend/component/src/components/member-components/invite-modal.tsx index be4eb92a7e..8b3366fe21 100644 --- a/packages/frontend/component/src/components/member-components/invite-modal.tsx +++ b/packages/frontend/component/src/components/member-components/invite-modal.tsx @@ -61,7 +61,7 @@ export const InviteModal = ({ confirmButtonOptions={{ loading: isMutating, variant: 'primary', - ['data-testid' as string]: 'confirm-enable-affine-cloud-button', + 'data-testid': 'confirm-enable-affine-cloud-button', }} onConfirm={handleConfirm} > diff --git a/packages/frontend/component/src/ui/button/button.tsx b/packages/frontend/component/src/ui/button/button.tsx index b02cf212e5..9a0cf95747 100644 --- a/packages/frontend/component/src/ui/button/button.tsx +++ b/packages/frontend/component/src/ui/button/button.tsx @@ -70,6 +70,7 @@ export interface ButtonProps tooltip?: TooltipProps['content']; tooltipShortcut?: TooltipProps['shortcut']; tooltipOptions?: Partial>; + [key: `data-${string}`]: string; } const IconSlot = ({ diff --git a/packages/frontend/component/src/ui/input/row-input.tsx b/packages/frontend/component/src/ui/input/row-input.tsx index d04049d859..b3c2cea8fc 100644 --- a/packages/frontend/component/src/ui/input/row-input.tsx +++ b/packages/frontend/component/src/ui/input/row-input.tsx @@ -20,6 +20,7 @@ export type RowInputProps = { type?: HTMLInputElement['type']; style?: CSSProperties; onEnter?: () => void; + [key: `data-${string}`]: string; } & Omit, 'onChange' | 'size' | 'onBlur'>; // RowInput component that is used in the selector layout for search input diff --git a/packages/frontend/component/src/ui/menu/desktop/sub.tsx b/packages/frontend/component/src/ui/menu/desktop/sub.tsx index 57a2e7e3c2..bda807970b 100644 --- a/packages/frontend/component/src/ui/menu/desktop/sub.tsx +++ b/packages/frontend/component/src/ui/menu/desktop/sub.tsx @@ -20,9 +20,9 @@ export const DesktopMenuSub = ({ } = {}, }: MenuSubProps) => { const { className, children, otherProps } = useMenuItem({ - ...triggerOptions, children: propsChildren, suffixIcon: , + ...triggerOptions, }); return ( diff --git a/packages/frontend/component/src/ui/menu/index.ts b/packages/frontend/component/src/ui/menu/index.ts index 3fa3d979b4..a3ac2f0d6f 100644 --- a/packages/frontend/component/src/ui/menu/index.ts +++ b/packages/frontend/component/src/ui/menu/index.ts @@ -30,3 +30,4 @@ export { }; export { Menu, MenuItem, MenuSeparator, MenuSub, MenuTrigger }; +export * from './mobile/hook'; diff --git a/packages/frontend/component/src/ui/menu/menu.types.ts b/packages/frontend/component/src/ui/menu/menu.types.ts index 06cf5403a1..ab04f7707d 100644 --- a/packages/frontend/component/src/ui/menu/menu.types.ts +++ b/packages/frontend/component/src/ui/menu/menu.types.ts @@ -36,7 +36,9 @@ export interface MenuItemProps export interface MenuSubProps { children: ReactNode; items: ReactNode; - triggerOptions?: Omit; + triggerOptions?: Omit & { + [key: `data-${string}`]: string; + }; portalOptions?: Omit; subOptions?: Omit; subContentOptions?: Omit; diff --git a/packages/frontend/component/src/ui/menu/mobile/context.ts b/packages/frontend/component/src/ui/menu/mobile/context.ts index ec91487a5f..fde6245de7 100644 --- a/packages/frontend/component/src/ui/menu/mobile/context.ts +++ b/packages/frontend/component/src/ui/menu/mobile/context.ts @@ -8,6 +8,11 @@ import { import type { MenuSubProps } from '../menu.types'; export type SubMenuContent = { + /** + * Customize submenu's title + * @default "Back" + */ + title?: string; items: ReactNode; contentOptions?: MenuSubProps['subContentOptions']; }; diff --git a/packages/frontend/component/src/ui/menu/mobile/hook.ts b/packages/frontend/component/src/ui/menu/mobile/hook.ts new file mode 100644 index 0000000000..5586bb0e32 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/mobile/hook.ts @@ -0,0 +1,18 @@ +import { useCallback, useContext } from 'react'; + +import { MobileMenuContext } from './context'; + +export const useMobileMenuController = () => { + const context = useContext(MobileMenuContext); + + /** + * **A workaround to close mobile menu manually** + * By default, it will close automatically when `MenuItem` clicked. + * For custom menu content, you can use this method to close the menu. + */ + const close = useCallback(() => { + context.setOpen?.(false); + }, [context]); + + return { close }; +}; diff --git a/packages/frontend/component/src/ui/menu/mobile/root.tsx b/packages/frontend/component/src/ui/menu/mobile/root.tsx index bf4b33c837..4586d5c584 100644 --- a/packages/frontend/component/src/ui/menu/mobile/root.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/root.tsx @@ -134,9 +134,9 @@ export const MobileMenu = ({ className={styles.backButton} prefix={} onClick={() => setSubMenus(prev => prev.slice(0, index))} - prefixStyle={{ width: 20, height: 20 }} + prefixStyle={{ width: 24, height: 24 }} > - {t['com.affine.backButton']()} + {sub.title || t['com.affine.backButton']()} {sub.items} diff --git a/packages/frontend/component/src/ui/menu/mobile/sub.tsx b/packages/frontend/component/src/ui/menu/mobile/sub.tsx index cb2c773c7b..2a0cca07ed 100644 --- a/packages/frontend/component/src/ui/menu/mobile/sub.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/sub.tsx @@ -7,19 +7,20 @@ import { useMenuItem } from '../use-menu-item'; import { MobileMenuContext } from './context'; export const MobileMenuSub = ({ + title, children: propsChildren, items, triggerOptions, subContentOptions: contentOptions = {}, -}: MenuSubProps) => { +}: MenuSubProps & { title?: string }) => { const { className, children, otherProps: { onClick, ...otherTriggerOptions }, } = useMenuItem({ - ...triggerOptions, children: propsChildren, suffixIcon: , + ...triggerOptions, }); return ( @@ -27,6 +28,7 @@ export const MobileMenuSub = ({ onClick={onClick} items={items} subContentOptions={contentOptions} + title={title} >
{children} @@ -36,19 +38,23 @@ export const MobileMenuSub = ({ }; export const MobileMenuSubRaw = ({ + title, onClick, children, items, subContentOptions: contentOptions = {}, -}: MenuSubProps & { onClick?: (e: MouseEvent) => void }) => { +}: MenuSubProps & { + onClick?: (e: MouseEvent) => void; + title?: string; +}) => { const { setSubMenus } = useContext(MobileMenuContext); const onItemClick = useCallback( (e: MouseEvent) => { onClick?.(e); - setSubMenus(prev => [...prev, { items, contentOptions }]); + setSubMenus(prev => [...prev, { items, contentOptions, title }]); }, - [contentOptions, items, onClick, setSubMenus] + [contentOptions, items, onClick, setSubMenus, title] ); return {children}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx index 3a85b5da31..b90e2bcf70 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/delete-leave-workspace/delete/index.tsx @@ -40,7 +40,7 @@ export const WorkspaceDeleteModal = ({ confirmButtonOptions={{ variant: 'error', disabled: !allowDelete, - ['data-testid' as string]: 'delete-workspace-confirm-button', + 'data-testid': 'delete-workspace-confirm-button', }} {...props} > diff --git a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx index b7a54ffcfd..b1250f91ee 100644 --- a/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx +++ b/packages/frontend/core/src/components/page-list/operation-menu-items/export.tsx @@ -127,7 +127,7 @@ export const Export = ({ exportHandler, className, pageMode }: ExportProps) => { triggerOptions={{ className: transitionStyle, prefixIcon: , - ['data-testid' as string]: 'export-menu', + 'data-testid': 'export-menu', }} subOptions={{ onOpenChange: handleExportMenuOpenChange, diff --git a/packages/frontend/core/src/components/page-list/operation-menu-items/snapshot.tsx b/packages/frontend/core/src/components/page-list/operation-menu-items/snapshot.tsx index 957d1f84e2..c10238863c 100644 --- a/packages/frontend/core/src/components/page-list/operation-menu-items/snapshot.tsx +++ b/packages/frontend/core/src/components/page-list/operation-menu-items/snapshot.tsx @@ -213,7 +213,7 @@ export const Snapshot = ({ className }: SnapshotProps) => { triggerOptions={{ className: transitionStyle, prefixIcon: , - ['data-testid' as string]: 'snapshot-menu', + 'data-testid': 'snapshot-menu', }} subOptions={{}} > diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/dialog.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/dialog.tsx new file mode 100644 index 0000000000..8c665facf7 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/dialog.tsx @@ -0,0 +1,43 @@ +import { useI18n } from '@affine/i18n'; + +import { + RenameDialog, + type RenameDialogProps, + RenameSubMenu, + type RenameSubMenuProps, +} from '../../../rename'; + +export const CollectionRenameSubMenu = ({ + title, + text, + ...props +}: RenameSubMenuProps) => { + const t = useI18n(); + return ( + + ); +}; + +const CollectionDesc = () => { + const t = useI18n(); + return t['com.affine.collection.emptyCollectionDescription'](); +}; + +export const CollectionRenameDialog = ({ + title, + confirmText, + ...props +}: RenameDialogProps) => { + return ( + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx index 898a10d898..40b56feb50 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx @@ -52,23 +52,6 @@ export const ExplorerCollectionNode = ({ const collection = useLiveData(collectionService.collection$(collectionId)); - const handleRename = useCallback( - (name: string) => { - if (collection && collection.name !== name) { - collectionService.updateCollection(collectionId, () => ({ - ...collection, - name, - })); - - track.$.navigationPanel.organize.renameOrganizeItem({ - type: 'collection', - }); - notify.success({ message: t['com.affine.toastMessage.rename']() }); - } - }, - [collection, collectionId, collectionService, t] - ); - const handleOpenCollapsed = useCallback(() => { setCollapsed(false); }, []); @@ -105,7 +88,7 @@ export const ExplorerCollectionNode = ({ return [...additionalOperations, ...collectionOperations]; } return collectionOperations; - }, [collectionOperations, additionalOperations]); + }, [additionalOperations, collectionOperations]); if (!collection) { return null; @@ -115,12 +98,10 @@ export const ExplorerCollectionNode = ({ diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx index 730b43a5f9..1d80355109 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx @@ -2,6 +2,7 @@ import { IconButton, MenuItem, MenuSeparator, + notify, useConfirmModal, } from '@affine/component'; import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; @@ -28,6 +29,8 @@ import { } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { CollectionRenameSubMenu } from './dialog'; + export const useExplorerCollectionNodeOperations = ( collectionId: string, onOpenCollapsed: () => void, @@ -113,6 +116,24 @@ export const useExplorerCollectionNodeOperations = ( onOpenEdit(); }, [onOpenEdit]); + const handleRename = useCallback( + (name: string) => { + const collection = collectionService.collection$(collectionId).value; + if (collection && collection.name !== name) { + collectionService.updateCollection(collectionId, () => ({ + ...collection, + name, + })); + + track.$.navigationPanel.organize.renameOrganizeItem({ + type: 'collection', + }); + notify.success({ message: t['com.affine.toastMessage.rename']() }); + } + }, + [collectionId, collectionService, t] + ); + return useMemo( () => ({ favorite, @@ -122,6 +143,7 @@ export const useExplorerCollectionNodeOperations = ( handleOpenInSplitView, handleShowEdit, handleToggleFavoriteCollection, + handleRename, }), [ favorite, @@ -129,6 +151,7 @@ export const useExplorerCollectionNodeOperations = ( handleDeleteCollection, handleOpenInNewTab, handleOpenInSplitView, + handleRename, handleShowEdit, handleToggleFavoriteCollection, ] @@ -154,6 +177,7 @@ export const useExplorerCollectionNodeOperationsMenu = ( handleOpenInSplitView, handleShowEdit, handleToggleFavoriteCollection, + handleRename, } = useExplorerCollectionNodeOperations( collectionId, onOpenCollapsed, @@ -177,6 +201,14 @@ export const useExplorerCollectionNodeOperationsMenu = ( ), }, + { + index: 10, + view: , + }, + { + index: 11, + view: , + }, { index: 99, view: ( @@ -256,6 +288,7 @@ export const useExplorerCollectionNodeOperationsMenu = ( handleDeleteCollection, handleOpenInNewTab, handleOpenInSplitView, + handleRename, handleShowEdit, handleToggleFavoriteCollection, t, diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/dialog.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/dialog.tsx new file mode 100644 index 0000000000..a11468a8b9 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/dialog.tsx @@ -0,0 +1,18 @@ +import { useI18n } from '@affine/i18n'; + +import { RenameSubMenu, type RenameSubMenuProps } from '../../../rename'; + +export const DocRenameSubMenu = ({ + title, + text, + ...props +}: RenameSubMenuProps) => { + const t = useI18n(); + return ( + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx index 5bdcad26d7..bb7611af27 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx @@ -1,11 +1,9 @@ import { Loading } from '@affine/component'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocInfoService } from '@affine/core/modules/doc-info'; import { DocsSearchService } from '@affine/core/modules/docs-search'; import type { NodeOperation } from '@affine/core/modules/explorer'; import { useI18n } from '@affine/i18n'; -import track from '@affine/track'; import { DocsService, FeatureFlagService, @@ -92,14 +90,6 @@ export const ExplorerDocNode = ({ ); }, [indexerLoading]); - const handleRename = useAsyncCallback( - async (newName: string) => { - await docsService.changeDocTitle(docId, newName); - track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' }); - }, - [docId, docsService] - ); - const docInfoModal = useService(DocInfoService).modal; const option = useMemo( () => ({ @@ -126,7 +116,6 @@ export const ExplorerDocNode = ({ ) } - onRename={handleRename} operations={finalOperations} data-testid={`explorer-doc-${docId}`} > diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx index 0f96897781..5902721cfd 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx @@ -33,6 +33,8 @@ import { } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { DocRenameSubMenu } from './dialog'; + export const useExplorerDocNodeOperations = ( docId: string, options: { @@ -135,6 +137,14 @@ export const useExplorerDocNodeOperations = ( }); }, [docId, compatibleFavoriteItemsAdapter]); + const handleRename = useAsyncCallback( + async (newName: string) => { + await docsService.changeDocTitle(docId, newName); + track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' }); + }, + [docId, docsService] + ); + return useMemo( () => ({ favorite, @@ -145,6 +155,7 @@ export const useExplorerDocNodeOperations = ( handleOpenInNewTab, handleMoveToTrash, handleOpenInfoModal, + handleRename, }), [ favorite, @@ -154,6 +165,7 @@ export const useExplorerDocNodeOperations = ( handleOpenInNewTab, handleOpenInSplitView, handleOpenInfoModal, + handleRename, handleToggleFavoriteDoc, ] ); @@ -177,8 +189,12 @@ export const useExplorerDocNodeOperationsMenu = ( handleOpenInNewTab, handleMoveToTrash, handleOpenInfoModal, + handleRename, } = useExplorerDocNodeOperations(docId, options); + const docService = useService(DocsService); + const docRecord = useLiveData(docService.list.doc$(docId)); + const title = useLiveData(docRecord?.title$); const enableMultiView = useLiveData( featureFlagService.flags.enable_multi_view.$ ); @@ -197,6 +213,14 @@ export const useExplorerDocNodeOperationsMenu = ( /> ), }, + { + index: 10, + view: , + }, + { + index: 11, + view: , + }, { index: 50, view: ( @@ -289,8 +313,10 @@ export const useExplorerDocNodeOperationsMenu = ( handleOpenInNewTab, handleOpenInSplitView, handleOpenInfoModal, + handleRename, handleToggleFavoriteDoc, t, + title, ] ); }; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/folder/dialog.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/dialog.tsx new file mode 100644 index 0000000000..230c269c11 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/dialog.tsx @@ -0,0 +1,58 @@ +import { useI18n } from '@affine/i18n'; +import { EditIcon } from '@blocksuite/icons/rc'; + +import type { RenameDialogProps, RenameSubMenuProps } from '../../../rename'; +import { RenameDialog, RenameSubMenu } from '../../../rename'; + +export const FolderCreateTip = ({ + input, + parentName, +}: { + input?: string; + parentName?: string; +}) => { + const t = useI18n(); + const parent = parentName + ? parentName + : t['com.affine.m.explorer.folder.root'](); + + const tip = input + ? t['com.affine.m.explorer.folder.new-tip-not-empty']({ + value: input, + parent, + }) + : t['com.affine.m.explorer.folder.new-tip-empty']({ parent }); + + return tip; +}; + +export const FolderRenameSubMenu = ({ + title: propsTitle, + icon: propsIcon, + text: propsText, + ...props +}: RenameSubMenuProps) => { + const t = useI18n(); + const title = propsTitle || t['com.affine.m.explorer.folder.rename'](); + const icon = propsIcon || ; + const text = propsText || title; + + return ; +}; + +export const FolderRenameDialog = ({ + title: propsTitle, + confirmText: propsConfirmText, + ...props +}: RenameDialogProps & { + open?: boolean; + onOpenChange?: (v: boolean) => void; +}) => { + const t = useI18n(); + const title = + propsTitle || t['com.affine.m.explorer.folder.new-dialog-title'](); + const confirmText = + propsConfirmText || t['com.affine.m.explorer.folder.rename-confirm'](); + + return ; +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx index 2f884cb779..cf3a5cfe0a 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx @@ -47,14 +47,13 @@ import { ExplorerTreeNode } from '../../tree/node'; import { ExplorerCollectionNode } from '../collection'; import { ExplorerDocNode } from '../doc'; import { ExplorerTagNode } from '../tag'; +import { FolderCreateTip, FolderRenameSubMenu } from './dialog'; import { FavoriteFolderOperation } from './operations'; export const ExplorerFolderNode = ({ nodeId, - defaultRenaming, operations, }: { - defaultRenaming?: boolean; nodeId: string; operations?: | NodeOperation[] @@ -83,11 +82,7 @@ export const ExplorerFolderNode = ({ if (type === 'folder') { return ( - + ); } if (!data) return null; @@ -123,10 +118,8 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({ const ExplorerFolderNodeFolder = ({ node, - defaultRenaming, operations: additionalOperations, }: { - defaultRenaming?: boolean; node: FolderNode; operations?: NodeOperation[]; }) => { @@ -144,7 +137,6 @@ const ExplorerFolderNodeFolder = ({ featureFlagService.flags.enable_emoji_folder_icon.$ ); const [collapsed, setCollapsed] = useState(true); - const [newFolderId, setNewFolderId] = useState(null); const { createPage } = usePageHelper( workspaceService.workspace.docCollection @@ -182,15 +174,14 @@ const ExplorerFolderNodeFolder = ({ setCollapsed(false); }, [createPage, node]); - const handleCreateSubfolder = useCallback(() => { - const newFolderId = node.createFolder( - t['com.affine.rootAppSidebar.organize.new-folders'](), - node.indexAt('before') - ); - track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); - setCollapsed(false); - setNewFolderId(newFolderId); - }, [node, t]); + const handleCreateSubfolder = useCallback( + (name: string) => { + node.createFolder(name, node.indexAt('before')); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + setCollapsed(false); + }, + [node] + ); const handleAddToFolder = useCallback( (type: 'doc' | 'collection' | 'tag') => { @@ -237,6 +228,13 @@ const ExplorerFolderNodeFolder = ({ ] ); + const createSubTipRenderer = useCallback( + ({ input }: { input: string }) => { + return ; + }, + [name] + ); + const folderOperations = useMemo(() => { return [ { @@ -254,12 +252,39 @@ const ExplorerFolderNodeFolder = ({ ), }, + { + index: 98, + view: ( + + ), + }, + { + index: 99, + view: , + }, { index: 100, view: ( - } onClick={handleCreateSubfolder}> - {t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()} - + } + menuProps={{ + triggerOptions: { 'data-testid': 'create-subfolder' }, + }} + /> ), }, { @@ -327,11 +352,14 @@ const ExplorerFolderNodeFolder = ({ }, ]; }, [ + createSubTipRenderer, handleAddToFolder, handleCreateSubfolder, handleDelete, handleNewDoc, - node, + handleRename, + name, + node.id, t, ]); @@ -370,7 +398,6 @@ const ExplorerFolderNodeFolder = ({ const handleCollapsedChange = useCallback((collapsed: boolean) => { if (collapsed) { - setNewFolderId(null); // reset new folder id to clear the renaming state setCollapsed(true); } else { setCollapsed(false); @@ -381,26 +408,25 @@ const ExplorerFolderNodeFolder = ({ {children.map(child => ( ))} handleAddToFolder('doc')} + data-testid="new-folder-in-folder-button" /> ); diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.css.ts b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.css.ts new file mode 100644 index 0000000000..99ebf2c7b0 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.css.ts @@ -0,0 +1,53 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const colorDot = style({ + width: 42, + height: 42, + textAlign: 'center', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + ':before': { + content: '""', + width: 22, + height: 22, + borderRadius: 11, + display: 'block', + background: 'currentColor', + }, +}); + +export const colorTrigger = style([ + colorDot, + { + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + selectors: { + '&[data-active="true"]': { + borderColor: cssVarV2('input/border/active'), + }, + }, + }, +]); + +export const colorsRow = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', + height: 54, + + selectors: { + // TODO(@CatsJuice): this animation is conflicting with sub-menu height detection + '&[data-enable-fold]': { + height: 0, + overflow: 'hidden', + transition: 'all 0.23s ease', + }, + '&[data-enable-fold][data-active="true"]': { + height: 54, + }, + }, +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.tsx new file mode 100644 index 0000000000..584b987da5 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/dialog.tsx @@ -0,0 +1,198 @@ +import { type MenuSubProps, useMobileMenuController } from '@affine/component'; +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { RenameContent, RenameSubMenu } from '../../../rename'; +import { RenameDialog } from '../../../rename/dialog'; +import type { RenameContentProps } from '../../../rename/type'; +import * as styles from './dialog.css'; + +const TagColorContext = createContext<{ + colors: string[]; + color: string; + setColor: Dispatch>; + show: boolean; + setShow: Dispatch>; + enableAnimation?: boolean; +}>({ + color: '', + setColor: () => {}, + colors: [], + show: false, + setShow: () => {}, +}); + +const ColorPickerTrigger = () => { + const { color, show, setShow } = useContext(TagColorContext); + return ( +
setShow(prev => !prev)} + /> + ); +}; + +const ColorPickerSelect = () => { + const { + enableAnimation, + colors, + color: current, + setColor, + show, + } = useContext(TagColorContext); + + if (!show && !enableAnimation) return null; + + return ( +
+ {colors.map(color => ( +
setColor(color)} + className={styles.colorDot} + style={{ color }} + data-color={color} + /> + ))} +
+ ); +}; + +interface TagRenameContentProps extends Omit { + initialColor?: string; + onConfirm?: (name: string, color: string) => void; + enableAnimation?: boolean; +} +const TagRenameContent = ({ + initialColor, + onConfirm, + enableAnimation, + ...props +}: TagRenameContentProps) => { + const tagService = useService(TagService); + const colors = useMemo(() => { + return tagService.tagColors.map(([_, value]) => value); + }, [tagService.tagColors]); + + const [color, setColor] = useState( + initialColor || tagService.randomTagColor() + ); + const [show, setShow] = useState(false); + + const handleConfirm = useCallback( + (name: string) => { + onConfirm?.(name, color); + }, + [color, onConfirm] + ); + + return ( + + + + ); +}; + +interface TagRenameDialogProps extends TagRenameContentProps { + title?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} +export const TagRenameDialog = ({ + title: propsTitle, + confirmText: propsConfirmText, + open, + onOpenChange, + ...props +}: TagRenameDialogProps) => { + const t = useI18n(); + const title = propsTitle || t['com.affine.m.explorer.tag.new-dialog-title'](); + const confirmText = + propsConfirmText || t['com.affine.m.explorer.tag.rename-confirm'](); + + return ( + + + + ); +}; + +interface TagRenameSubMenuProps { + tagId?: string; + title?: string; + icon?: ReactNode; + text?: string; + onConfirm?: (name: string, color: string) => void; + menuProps?: Partial; +} +export const TagRenameSubMenu = ({ + tagId, + title, + icon, + text, + menuProps, + onConfirm, +}: TagRenameSubMenuProps) => { + const t = useI18n(); + const { close } = useMobileMenuController(); + const tagService = useService(TagService); + const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); + const tagName = useLiveData(tagRecord?.value$); + const tagColor = useLiveData(tagRecord?.color$); + + const handleCloseAndConfirm = useCallback( + (name: string, color: string) => { + close(); + onConfirm?.(name, color); + }, + [close, onConfirm] + ); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx index 2d939bf445..271ab27625 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx @@ -2,7 +2,6 @@ import type { NodeOperation } from '@affine/core/modules/explorer'; import type { Tag } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag'; import { useI18n } from '@affine/i18n'; -import track from '@affine/track'; import { GlobalContextService, useLiveData, @@ -23,10 +22,8 @@ import * as styles from './styles.css'; export const ExplorerTagNode = ({ tagId, operations: additionalOperations, - defaultRenaming, }: { tagId: string; - defaultRenaming?: boolean; operations?: NodeOperation[]; }) => { const t = useI18n(); @@ -47,6 +44,7 @@ export const ExplorerTagNode = ({ return (
{ - if (tagRecord && tagRecord.value$.value !== newName) { - tagRecord.rename(newName); - track.$.navigationPanel.organize.renameOrganizeItem({ - type: 'tag', - }); - } - }, - [tagRecord] - ); - const option = useMemo( () => ({ openNodeCollapsed: () => setCollapsed(false), @@ -94,15 +80,14 @@ export const ExplorerTagNode = ({ diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx index 967113ad06..8d87169eb8 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx @@ -7,12 +7,7 @@ import { TagService } from '@affine/core/modules/tag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; -import { - DeleteIcon, - OpenInNewIcon, - PlusIcon, - SplitViewIcon, -} from '@blocksuite/icons/rc'; +import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc'; import { DocsService, FeatureFlagService, @@ -23,6 +18,8 @@ import { } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; +import { TagRenameSubMenu } from './dialog'; + export const useExplorerTagNodeOperations = ( tagId: string, { @@ -86,6 +83,37 @@ export const useExplorerTagNodeOperations = ( track.$.navigationPanel.organize.openInNewTab({ type: 'tag' }); }, [tagId, workbenchService]); + const handleRename = useCallback( + (newName: string) => { + if (tagRecord && tagRecord.value$.value !== newName) { + tagRecord.rename(newName); + track.$.navigationPanel.organize.renameOrganizeItem({ + type: 'tag', + }); + } + }, + [tagRecord] + ); + const handleChangeColor = useCallback( + (color: string) => { + if (tagRecord && tagRecord.color$.value !== color) { + tagRecord.changeColor(color); + } + }, + [tagRecord] + ); + const handleChangeNameOrColor = useCallback( + (name?: string, color?: string) => { + if (name !== undefined) { + handleRename(name); + } + if (color !== undefined) { + handleChangeColor(color); + } + }, + [handleChangeColor, handleRename] + ); + return useMemo( () => ({ favorite, @@ -94,13 +122,19 @@ export const useExplorerTagNodeOperations = ( handleOpenInSplitView, handleToggleFavoriteTag, handleOpenInNewTab, + handleRename, + handleChangeColor, + handleChangeNameOrColor, }), [ favorite, + handleChangeColor, + handleChangeNameOrColor, handleMoveToTrash, handleNewDoc, handleOpenInNewTab, handleOpenInSplitView, + handleRename, handleToggleFavoriteTag, ] ); @@ -122,7 +156,7 @@ export const useExplorerTagNodeOperationsMenu = ( handleMoveToTrash, handleOpenInSplitView, handleToggleFavoriteTag, - handleOpenInNewTab, + handleChangeNameOrColor, } = useExplorerTagNodeOperations(tagId, option); return useMemo( @@ -131,21 +165,19 @@ export const useExplorerTagNodeOperationsMenu = ( index: 0, inline: true, view: ( - + ), }, { - index: 50, + index: 10, view: ( - } onClick={handleOpenInNewTab}> - {t['com.affine.workbench.tab.page-menu-open']()} - + ), }, ...(BUILD_CONFIG.isElectron && enableMultiView @@ -196,12 +228,13 @@ export const useExplorerTagNodeOperationsMenu = ( [ enableMultiView, favorite, + handleChangeNameOrColor, handleMoveToTrash, handleNewDoc, - handleOpenInNewTab, handleOpenInSplitView, handleToggleFavoriteTag, t, + tagId, ] ); }; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx index d2bf33fa74..186ed64e52 100644 --- a/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx @@ -1,4 +1,3 @@ -import { useEditCollectionName } from '@affine/core/components/page-list'; import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager'; import { CollectionService } from '@affine/core/modules/collection'; import { ExplorerService } from '@affine/core/modules/explorer'; @@ -8,11 +7,12 @@ import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { useLiveData, useServices } from '@toeverything/infra'; import { nanoid } from 'nanoid'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { CollapsibleSection } from '../../layouts/collapsible-section'; import { ExplorerCollectionNode } from '../../nodes/collection'; +import { CollectionRenameDialog } from '../../nodes/collection/dialog'; export const ExplorerCollections = () => { const t = useI18n(); @@ -23,31 +23,22 @@ export const ExplorerCollections = () => { }); const explorerSection = explorerService.sections.collections; const collections = useLiveData(collectionService.collections$); - const { open: openCreateCollectionModel } = useEditCollectionName({ - title: t['com.affine.editCollection.createCollection'](), - showTips: true, - }); + const [showCreateCollectionModal, setShowCreateCollectionModal] = + useState(false); - const handleCreateCollection = useCallback(() => { - openCreateCollectionModel('') - .then(name => { - const id = nanoid(); - collectionService.addCollection(createEmptyCollection(id, { name })); - track.$.navigationPanel.organize.createOrganizeItem({ - type: 'collection', - }); - workbenchService.workbench.openCollection(id); - explorerSection.setCollapsed(false); - }) - .catch(err => { - console.error(err); + const handleCreateCollection = useCallback( + (name: string) => { + setShowCreateCollectionModal(false); + const id = nanoid(); + collectionService.addCollection(createEmptyCollection(id, { name })); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'collection', }); - }, [ - collectionService, - explorerSection, - openCreateCollectionModel, - workbenchService.workbench, - ]); + workbenchService.workbench.openCollection(id); + explorerSection.setCollapsed(false); + }, + [collectionService, explorerSection, workbenchService.workbench] + ); return ( { setShowCreateCollectionModal(true)} + /> + diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx index 601e2a6b22..60fd4592f7 100644 --- a/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx @@ -7,11 +7,12 @@ import { OrganizeService } from '@affine/core/modules/organize'; import { useI18n } from '@affine/i18n'; import track from '@affine/track'; import { useLiveData, useServices } from '@toeverything/infra'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { CollapsibleSection } from '../../layouts/collapsible-section'; import { ExplorerFolderNode } from '../../nodes/folder'; +import { FolderCreateTip, FolderRenameDialog } from '../../nodes/folder/dialog'; export const ExplorerOrganize = () => { const { organizeService, explorerService } = useServices({ @@ -19,8 +20,7 @@ export const ExplorerOrganize = () => { ExplorerService, }); const explorerSection = explorerService.sections.organize; - const collapsed = useLiveData(explorerSection.collapsed$); - const [newFolderId, setNewFolderId] = useState(null); + const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); const t = useI18n(); @@ -30,20 +30,18 @@ export const ExplorerOrganize = () => { const folders = useLiveData(rootFolder.sortedChildren$); const isLoading = useLiveData(folderTree.isLoading$); - const handleCreateFolder = useCallback(() => { - const newFolderId = rootFolder.createFolder( - 'New Folder', - rootFolder.indexAt('before') - ); - track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); - setNewFolderId(newFolderId); - explorerSection.setCollapsed(false); - return newFolderId; - }, [explorerSection, rootFolder]); - - useEffect(() => { - if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state - }, [collapsed]); + const handleCreateFolder = useCallback( + (name: string) => { + const newFolderId = rootFolder.createFolder( + name, + rootFolder.indexAt('before') + ); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + explorerSection.setCollapsed(false); + return newFolderId; + }, + [explorerSection, rootFolder] + ); return ( { {/* TODO(@CatsJuice): Organize loading UI */} : null}> {folders.map(child => ( - + ))} setOpenNewFolderDialog(true)} /> + ); }; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx index be9a77fa96..d18b4ecc00 100644 --- a/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx @@ -1,15 +1,23 @@ import { ExplorerService } from '@affine/core/modules/explorer'; import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; -import type { Tag } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { useLiveData, useServices } from '@toeverything/infra'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { CollapsibleSection } from '../../layouts/collapsible-section'; import { ExplorerTagNode } from '../../nodes/tag'; +import { TagRenameDialog } from '../../nodes/tag/dialog'; + +export const TagDesc = ({ input }: { input: string }) => { + const t = useI18n(); + + return input + ? t['com.affine.m.explorer.tag.new-tip-not-empty']({ value: input }) + : t['com.affine.m.explorer.tag.new-tip-empty'](); +}; export const ExplorerTags = () => { const { tagService, explorerService } = useServices({ @@ -17,25 +25,20 @@ export const ExplorerTags = () => { ExplorerService, }); const explorerSection = explorerService.sections.tags; - const collapsed = useLiveData(explorerSection.collapsed$); - const [createdTag, setCreatedTag] = useState(null); const tags = useLiveData(tagService.tagList.tags$); + const [showNewTagDialog, setShowNewTagDialog] = useState(false); const t = useI18n(); - const handleCreateNewFavoriteDoc = useCallback(() => { - const newTags = tagService.tagList.createTag( - t['com.affine.rootAppSidebar.tags.new-tag'](), - tagService.randomTagColor() - ); - setCreatedTag(newTags); - track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' }); - explorerSection.setCollapsed(false); - }, [explorerSection, t, tagService]); - - useEffect(() => { - if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state - }, [collapsed]); + const handleNewTag = useCallback( + (name: string, color: string) => { + setShowNewTagDialog(false); + tagService.tagList.createTag(name, color); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' }); + explorerSection.setCollapsed(false); + }, + [explorerSection, tagService] + ); return ( { > {tags.map(tag => ( - + ))} setShowNewTagDialog(true)} label={t[ 'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip' ]()} /> + ); diff --git a/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx index 495eb6ad24..4af80579c9 100644 --- a/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx @@ -1,17 +1,10 @@ -import { MenuItem, MobileMenu } from '@affine/component'; -import { RenameModal } from '@affine/component/rename-modal'; -import { AppSidebarService } from '@affine/core/modules/app-sidebar'; -import type { - BaseExplorerTreeNodeProps, - NodeOperation, -} from '@affine/core/modules/explorer'; +import { MobileMenu } from '@affine/component'; +import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer'; import { ExplorerTreeContext } from '@affine/core/modules/explorer'; import { WorkbenchLink } from '@affine/core/modules/workbench'; import { extractEmojiIcon } from '@affine/core/utils'; -import { useI18n } from '@affine/i18n'; -import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/rc'; import * as Collapsible from '@radix-ui/react-collapsible'; -import { useLiveData, useService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { Fragment, @@ -33,9 +26,6 @@ export const ExplorerTreeNode = ({ onClick, to, active, - defaultRenaming, - renameable, - onRename, disabled, collapsed, extractEmojiAsIcon, @@ -47,18 +37,13 @@ export const ExplorerTreeNode = ({ linkComponent: LinkComponent = WorkbenchLink, ...otherProps }: ExplorerTreeNodeProps) => { - const t = useI18n(); const context = useContext(ExplorerTreeContext); const level = context?.level ?? 0; // If no onClick or to is provided, clicking on the node will toggle the collapse state const clickForCollapse = !onClick && !to && !disabled; const [childCount, setChildCount] = useState(0); - const [renaming, setRenaming] = useState(defaultRenaming); const rootRef = useRef(null); - const appSidebarService = useService(AppSidebarService).sidebar; - const sidebarWidth = useLiveData(appSidebarService.width$); - const { emoji, name } = useMemo(() => { if (!extractEmojiAsIcon || !rawName) { return { @@ -73,39 +58,13 @@ export const ExplorerTreeNode = ({ }; }, [extractEmojiAsIcon, rawName]); - const presetOperations = useMemo( - () => - ( - [ - renameable - ? { - index: 0, - view: ( - } - onClick={() => setRenaming(true)} - > - {t['com.affine.menu.rename']()} - - ), - } - : null, - ] as (NodeOperation | null)[] - ).filter((t): t is NodeOperation => t !== null), - [renameable, t] - ); - const { menuOperations } = useMemo(() => { - const sorted = [...presetOperations, ...operations].sort( - (a, b) => a.index - b.index - ); + const sorted = [...operations].sort((a, b) => a.index - b.index); return { menuOperations: sorted.filter(({ inline }) => !inline), inlineOperations: sorted.filter(({ inline }) => !!inline), }; - }, [presetOperations, operations]); + }, [operations]); const contextValue = useMemo(() => { return { @@ -127,11 +86,6 @@ export const ExplorerTreeNode = ({ [collapsed, setCollapsed] ); - const handleRename = useCallback( - (newName: string) => onRename?.(newName), - [onRename] - ); - const handleClick = useCallback( (e: React.MouseEvent) => { if (e.defaultPrevented) { @@ -166,7 +120,7 @@ export const ExplorerTreeNode = ({ {view} ))} > -
+
{emoji ?? (Icon && )}
@@ -193,18 +147,6 @@ export const ExplorerTreeNode = ({ data-collapsed={collapsed !== false} />
- - {renameable && ( - -
- - )}
); diff --git a/packages/frontend/core/src/mobile/components/index.ts b/packages/frontend/core/src/mobile/components/index.ts index c6d2724372..18dd7f3f65 100644 --- a/packages/frontend/core/src/mobile/components/index.ts +++ b/packages/frontend/core/src/mobile/components/index.ts @@ -1,6 +1,7 @@ export * from './app-tabs'; export * from './doc-card'; export * from './page-header'; +export * from './rename'; export * from './search-input'; export * from './search-result'; export * from './user-plan-tag'; diff --git a/packages/frontend/core/src/mobile/components/rename/content.css.ts b/packages/frontend/core/src/mobile/components/rename/content.css.ts new file mode 100644 index 0000000000..1e7de0ff0e --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/content.css.ts @@ -0,0 +1,35 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const inputWrapper = style({ + padding: '4px 12px', + display: 'flex', + alignItems: 'center', + gap: 10, +}); +export const input = style({ + width: '100%', + height: 42, + border: '1px solid ' + cssVarV2('input/border/active'), + borderRadius: 8, + padding: '0 4px', +}); +export const desc = style({ + padding: '11px 16px', + fontSize: 17, + fontWeight: 400, + lineHeight: '22px', + letterSpacing: -0.43, + color: cssVarV2('text/secondary'), +}); +export const doneWrapper = style({ + width: '100%', + padding: '8px 16px', +}); +export const done = style({ + width: '100%', + height: 44, + borderRadius: 8, + fontSize: 17, + fontWeight: 400, +}); diff --git a/packages/frontend/core/src/mobile/components/rename/content.tsx b/packages/frontend/core/src/mobile/components/rename/content.tsx new file mode 100644 index 0000000000..18b5674d94 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/content.tsx @@ -0,0 +1,67 @@ +import { Button, RowInput } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import clsx from 'clsx'; +import { useCallback, useState } from 'react'; + +import * as styles from './content.css'; +import type { RenameContentProps } from './type'; + +export const RenameContent = ({ + initialName = '', + inputProps, + confirmButtonProps, + inputPrefixRenderer: InputPrefixRenderer, + inputBelowRenderer: InputBelowRenderer, + descRenderer: DescRenderer, + confirmText = 'Done', + onConfirm, +}: RenameContentProps) => { + const t = useI18n(); + const [value, setValue] = useState(initialName); + + const { className: inputClassName, ...restInputProps } = inputProps ?? {}; + const { className: confirmButtonClassName, ...restConfirmButtonProps } = + confirmButtonProps ?? {}; + + const handleDone = useCallback(() => { + onConfirm?.(value); + }, [onConfirm, value]); + + return ( +
+
+ {InputPrefixRenderer ? : null} + +
+ {} + {InputBelowRenderer ? : null} +
+ {DescRenderer ? ( + + ) : ( + t['com.affine.m.rename-to']({ name: value }) + )} +
+
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/rename/dialog.css.ts b/packages/frontend/core/src/mobile/components/rename/dialog.css.ts new file mode 100644 index 0000000000..fe0994c039 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/dialog.css.ts @@ -0,0 +1,17 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const header = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + padding: '10px 16px', +}); +export const title = style({ + fontSize: 17, + fontWeight: 600, + lineHeight: '22px', + letterSpacing: -0.43, + color: cssVarV2('text/primary'), +}); diff --git a/packages/frontend/core/src/mobile/components/rename/dialog.tsx b/packages/frontend/core/src/mobile/components/rename/dialog.tsx new file mode 100644 index 0000000000..8932aae912 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/dialog.tsx @@ -0,0 +1,49 @@ +import { IconButton, Modal } from '@affine/component'; +import { CloseIcon } from '@blocksuite/icons/rc'; +import { useCallback } from 'react'; + +import { RenameContent } from './content'; +import * as styles from './dialog.css'; +import type { RenameDialogProps } from './type'; + +export const RenameDialog = ({ + open, + title, + onOpenChange, + onConfirm, + children, + ...props +}: RenameDialogProps & { + open?: boolean; + onOpenChange?: (v: boolean) => void; +}) => { + const handleRename = useCallback( + (value: string) => { + onConfirm?.(value); + onOpenChange?.(false); + }, + [onOpenChange, onConfirm] + ); + + const close = useCallback(() => { + onOpenChange?.(false); + }, [onOpenChange]); + + return ( + +
+ {title} + } onClick={close} /> +
+ {children ?? } +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/rename/index.tsx b/packages/frontend/core/src/mobile/components/rename/index.tsx new file mode 100644 index 0000000000..bd4a345a1b --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/index.tsx @@ -0,0 +1,4 @@ +export * from './content'; +export * from './dialog'; +export * from './sub-menu'; +export * from './type'; diff --git a/packages/frontend/core/src/mobile/components/rename/sub-menu.tsx b/packages/frontend/core/src/mobile/components/rename/sub-menu.tsx new file mode 100644 index 0000000000..34aff36339 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/sub-menu.tsx @@ -0,0 +1,53 @@ +import { MobileMenuSub, useMobileMenuController } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { EditIcon } from '@blocksuite/icons/rc'; +import { useCallback } from 'react'; + +import { RenameContent } from './content'; +import type { RenameSubMenuProps } from './type'; + +export const RenameSubMenu = ({ + initialName = '', + title, + icon, + text, + children, + menuProps, + onConfirm, + ...props +}: RenameSubMenuProps) => { + const t = useI18n(); + const { close } = useMobileMenuController(); + + const handleRename = useCallback( + (value: string) => { + onConfirm?.(value); + close(); + }, + [close, onConfirm] + ); + + const { triggerOptions, ...otherMenuProps } = menuProps ?? {}; + return ( + , + suffixIcon: null, + ...triggerOptions, + }} + items={ + children ?? ( + + ) + } + title={title} + {...otherMenuProps} + > + {text ?? t['com.affine.m.explorer.folder.rename']()} + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/rename/type.ts b/packages/frontend/core/src/mobile/components/rename/type.ts new file mode 100644 index 0000000000..750918203e --- /dev/null +++ b/packages/frontend/core/src/mobile/components/rename/type.ts @@ -0,0 +1,38 @@ +import type { + ButtonProps, + MenuSubProps, + RowInputProps, +} from '@affine/component'; +import type { PropsWithChildren, ReactNode } from 'react'; + +export interface RenameBaseProps { + initialName?: string; + onConfirm?: (name: string) => void; +} + +export interface RenameContentProps extends RenameBaseProps { + inputProps?: Omit; + confirmButtonProps?: Omit; + confirmText?: string; + inputPrefixRenderer?: (props: { input: string }) => ReactNode; + descRenderer?: (props: { input: string }) => ReactNode; + inputBelowRenderer?: (props: { input: string }) => ReactNode; +} + +export interface RenameSubMenuProps + extends PropsWithChildren { + /** Submenu's title */ + title?: string; + /** MenuItem.icon */ + icon?: ReactNode; + /** MenuItem.text */ + text?: string; + menuProps?: Partial; +} + +export interface RenameDialogProps + extends PropsWithChildren { + open?: boolean; + onOpenChange?: (open: boolean) => void; + title?: string; +} diff --git a/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx b/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx index 447f4e081a..fffb57bb80 100644 --- a/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx +++ b/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx @@ -95,7 +95,7 @@ const NameWorkspaceContent = ({ variant: 'primary', loading, disabled: !workspaceName, - ['data-testid' as string]: 'create-workspace-create-button', + 'data-testid': 'create-workspace-create-button', }} closeButtonOptions={{ ['data-testid' as string]: 'create-workspace-close-button', diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx index 2d011fd297..6bb4bcba02 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx @@ -61,12 +61,9 @@ export interface BaseExplorerTreeNodeProps { icon?: ExplorerTreeNodeIcon; children?: React.ReactNode; active?: boolean; - defaultRenaming?: boolean; extractEmojiAsIcon?: boolean; collapsed: boolean; setCollapsed: (collapsed: boolean) => void; - renameable?: boolean; - onRename?: (newName: string) => void; disabled?: boolean; onClick?: () => void; to?: To; @@ -81,6 +78,10 @@ export interface BaseExplorerTreeNodeProps { } interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps { + renameable?: boolean; + onRename?: (newName: string) => void; + defaultRenaming?: boolean; + canDrop?: DropTargetOptions['canDrop']; reorderable?: boolean; dndData?: AffineDNDData; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index f469c0a978..e99e217acc 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -18,5 +18,5 @@ "sv-SE": 5, "ur": 3, "zh-Hans": 98, - "zh-Hant": 96 + "zh-Hant": 97 } \ No newline at end of file diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 271bfd23c3..2e8b1ffafb 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1352,5 +1352,22 @@ "system": "System", "unnamed": "unnamed", "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.", - "com.affine.workspace.properties": "Workspace properties" + "com.affine.workspace.properties": "Workspace properties", + "com.affine.m.rename-to": "Rename to \"{{name}}\"", + "com.affine.m.explorer.folder.rename": "Rename", + "com.affine.m.explorer.folder.new-dialog-title": "Create Folder", + "com.affine.m.explorer.folder.root": "Organize", + "com.affine.m.explorer.folder.new-tip-empty": "Create a folder in the {{parent}}.", + "com.affine.m.explorer.folder.new-tip-not-empty": "Create \"{{value}}\" in the {{parent}}.", + "com.affine.m.explorer.folder.rename-confirm": "Done", + "com.affine.m.explorer.tag.rename": "Rename", + "com.affine.m.explorer.tag.rename-menu-title": "Rename Tag", + "com.affine.m.explorer.tag.new-dialog-title": "Create Tag", + "com.affine.m.explorer.tag.rename-confirm": "Done", + "com.affine.m.explorer.tag.new-tip-empty": "Create a tag in this workspace.", + "com.affine.m.explorer.tag.new-tip-not-empty": "Create \"{{value}}\" tag in this workspace.", + "com.affine.m.explorer.collection.rename": "Rename", + "com.affine.m.explorer.collection.rename-menu-title": "Rename Collection", + "com.affine.m.explorer.collection.new-dialog-title": "Create Collection", + "com.affine.m.explorer.doc.rename": "Rename" } diff --git a/tests/affine-mobile/e2e/explorer-folder.spec.ts b/tests/affine-mobile/e2e/explorer-folder.spec.ts new file mode 100644 index 0000000000..defb3a1655 --- /dev/null +++ b/tests/affine-mobile/e2e/explorer-folder.spec.ts @@ -0,0 +1,76 @@ +import { test } from '@affine-test/kit/mobile'; +import { expect, type Locator, type Page } from '@playwright/test'; + +import { + expandCollapsibleSection, + getAttrOfActiveElement, + openExplorerNodeMenu, +} from './utils'; + +const locateFolder = async (scope: Page | Locator, name: string) => { + return scope.locator(`[data-role="explorer-folder"][aria-label="${name}"]`); +}; + +/** + * Check rename input is focused + */ +const isRenameInputFocused = async (page: Page) => { + const focusElTestid = await getAttrOfActiveElement(page); + expect(focusElTestid).toEqual('rename-input'); +}; + +const createRootFolder = async (page: Page, name: string) => { + const section = await expandCollapsibleSection(page, 'organize'); + await section.getByTestId('explorer-bar-add-organize-button').tap(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await isRenameInputFocused(page); + await page.keyboard.type(name); + await dialog.getByTestId('rename-confirm').tap(); + await expect(dialog).not.toBeVisible(); + const node = await locateFolder(section, name); + return node; +}; + +const createSubFolder = async (page: Page, parent: Locator, name: string) => { + const menu = await openExplorerNodeMenu(page, parent); + await menu.getByTestId('create-subfolder').tap(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await isRenameInputFocused(page); + await page.keyboard.type(name); + await dialog.getByTestId('rename-confirm').tap(); + await expect(dialog).not.toBeVisible(); + const node = await locateFolder(parent, name); + return node; +}; + +test('create a folder', async ({ page }) => { + const node = await createRootFolder(page, 'Test Folder'); + await expect(node).toBeVisible(); +}); + +test('create a sub folder', async ({ page }) => { + const parent = await createRootFolder(page, 'Parent Folder'); + await expect(parent).toBeVisible(); + await parent.tap(); + const child = await createSubFolder(page, parent, 'Child Folder'); + await expect(child).toBeVisible(); +}); + +test('create a folder and rename it', async ({ page }) => { + const originalName = 'Test Folder'; + const appendedName = ' Renamed'; + + const folder = await createRootFolder(page, originalName); + const menu = await openExplorerNodeMenu(page, folder); + await menu.getByTestId('rename-folder').tap(); + await isRenameInputFocused(page); + await page.keyboard.type(appendedName); + await menu.getByTestId('rename-confirm').tap(); + + const renamedFolder = await locateFolder(page, originalName + appendedName); + await expect(folder).not.toBeVisible(); + await expect(renamedFolder).toBeVisible(); +}); diff --git a/tests/affine-mobile/e2e/explorer-tag.spec.ts b/tests/affine-mobile/e2e/explorer-tag.spec.ts new file mode 100644 index 0000000000..788b178303 --- /dev/null +++ b/tests/affine-mobile/e2e/explorer-tag.spec.ts @@ -0,0 +1,87 @@ +import { test } from '@affine-test/kit/mobile'; +import { expect, type Locator, type Page } from '@playwright/test'; + +import { + expandCollapsibleSection, + getAttrOfActiveElement, + openExplorerNodeMenu, +} from './utils'; + +async function locateTag(scope: Page | Locator, name: string) { + return scope.locator(`[data-role="explorer-tag"][aria-label="${name}"]`); +} + +async function getExplorerTagColor(tagNode: Locator) { + const icon = tagNode.getByTestId('explorer-tag-icon-dot'); + await expect(icon).toBeVisible(); + const color = await icon.evaluate(el => el.style.backgroundColor); + return color; +} + +async function changeTagColor(scope: Locator, color: string) { + const trigger = scope.getByTestId('tag-color-picker-trigger'); + const select = scope.getByTestId('tag-color-picker-select'); + await trigger.tap(); + await expect(select).toBeVisible(); + const colorDot = select.locator(`[data-color="${color}"]`); + await colorDot.tap(); +} + +async function createRootTag( + page: Page, + name: string, + color = 'var(--affine-palette-line-red)' +) { + const section = await expandCollapsibleSection(page, 'tags'); + await section.getByTestId('explorer-add-tag-button').tap(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + // input name + const focusedTestid = await getAttrOfActiveElement(page); + expect(focusedTestid).toEqual('rename-input'); + await page.keyboard.type(name); + // set color + await changeTagColor(dialog, color); + // confirm + await dialog.getByTestId('rename-confirm').tap(); + const tag = await locateTag(section, name); + await expect(tag).toBeVisible(); + // check tag color + const fill = await getExplorerTagColor(tag); + expect(fill).toEqual(color); + return tag; +} + +test('create a tag from explorer', async ({ page }) => { + await createRootTag(page, 'Test Tag'); +}); + +test('rename a tag from explorer', async ({ page }) => { + const originalName = 'Test Tag'; + const appendedName = ' Renamed'; + + const tag = await createRootTag(page, originalName); + const menu = await openExplorerNodeMenu(page, tag); + await menu.getByTestId('rename-tag').tap(); + const focusedTestid = await getAttrOfActiveElement(page); + expect(focusedTestid).toEqual('rename-input'); + await page.keyboard.type(appendedName); + await menu.getByTestId('rename-confirm').tap(); + await expect(tag).not.toBeVisible(); + const renamedTag = await locateTag(page, originalName + appendedName); + await expect(renamedTag).toBeVisible(); +}); + +test('change tag color from explorer', async ({ page }) => { + const newColor = 'var(--affine-palette-line-green)'; + const tagName = 'Test Tag'; + const tag = await createRootTag(page, tagName); + const menu = await openExplorerNodeMenu(page, tag); + await menu.getByTestId('rename-tag').tap(); + await changeTagColor(menu, newColor); + await menu.getByTestId('rename-confirm').tap(); + + const updatedTag = await locateTag(page, tagName); + const fill = await getExplorerTagColor(updatedTag); + expect(fill).toEqual(newColor); +}); diff --git a/tests/affine-mobile/e2e/utils.ts b/tests/affine-mobile/e2e/utils.ts index 1fbaa6fb36..4a71832ff7 100644 --- a/tests/affine-mobile/e2e/utils.ts +++ b/tests/affine-mobile/e2e/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable unicorn/prefer-dom-node-dataset */ -import { expect, type Page } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; export async function expandCollapsibleSection(page: Page, name: string) { const divider = page.locator(`[data-collapsible]:has-text("${name}")`); @@ -20,3 +20,24 @@ export async function expandCollapsibleSection(page: Page, name: string) { export async function pageBack(page: Page) { await page.getByTestId('page-header-back').tap(); } + +export async function getAttrOfActiveElement( + page: Page, + attrName = 'data-testid' +) { + return await page.evaluate(name => { + const el = document.activeElement; + return el ? el.getAttribute(name) : ''; + }, attrName); +} + +/** + * Open the context menu of an explorer node + * @returns Menu Locator + */ +export async function openExplorerNodeMenu(page: Page, node: Locator) { + await node.getByTestId('menu-trigger').tap(); + const menu = page.getByRole('dialog'); + await expect(menu).toBeVisible(); + return menu; +}