feat(mobile): explorer create/rename operation (#8628)

close AF-1560
This commit is contained in:
CatsJuice
2024-11-04 05:28:05 +00:00
parent 12e3cf1d07
commit 4cbf4b74d6
44 changed files with 1139 additions and 252 deletions
@@ -61,7 +61,7 @@ export const InviteModal = ({
confirmButtonOptions={{ confirmButtonOptions={{
loading: isMutating, loading: isMutating,
variant: 'primary', variant: 'primary',
['data-testid' as string]: 'confirm-enable-affine-cloud-button', 'data-testid': 'confirm-enable-affine-cloud-button',
}} }}
onConfirm={handleConfirm} onConfirm={handleConfirm}
> >
@@ -70,6 +70,7 @@ export interface ButtonProps
tooltip?: TooltipProps['content']; tooltip?: TooltipProps['content'];
tooltipShortcut?: TooltipProps['shortcut']; tooltipShortcut?: TooltipProps['shortcut'];
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>; tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
[key: `data-${string}`]: string;
} }
const IconSlot = ({ const IconSlot = ({
@@ -20,6 +20,7 @@ export type RowInputProps = {
type?: HTMLInputElement['type']; type?: HTMLInputElement['type'];
style?: CSSProperties; style?: CSSProperties;
onEnter?: () => void; onEnter?: () => void;
[key: `data-${string}`]: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>; } & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
// RowInput component that is used in the selector layout for search input // RowInput component that is used in the selector layout for search input
@@ -20,9 +20,9 @@ export const DesktopMenuSub = ({
} = {}, } = {},
}: MenuSubProps) => { }: MenuSubProps) => {
const { className, children, otherProps } = useMenuItem({ const { className, children, otherProps } = useMenuItem({
...triggerOptions,
children: propsChildren, children: propsChildren,
suffixIcon: <ArrowRightSmallIcon />, suffixIcon: <ArrowRightSmallIcon />,
...triggerOptions,
}); });
return ( return (
@@ -30,3 +30,4 @@ export {
}; };
export { Menu, MenuItem, MenuSeparator, MenuSub, MenuTrigger }; export { Menu, MenuItem, MenuSeparator, MenuSub, MenuTrigger };
export * from './mobile/hook';
@@ -36,7 +36,9 @@ export interface MenuItemProps
export interface MenuSubProps { export interface MenuSubProps {
children: ReactNode; children: ReactNode;
items: ReactNode; items: ReactNode;
triggerOptions?: Omit<MenuItemProps, 'onSelect' | 'children' | 'suffixIcon'>; triggerOptions?: Omit<MenuItemProps, 'onSelect' | 'children'> & {
[key: `data-${string}`]: string;
};
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>; portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
subOptions?: Omit<DropdownMenuSubProps, 'children'>; subOptions?: Omit<DropdownMenuSubProps, 'children'>;
subContentOptions?: Omit<DropdownMenuSubContentProps, 'children'>; subContentOptions?: Omit<DropdownMenuSubContentProps, 'children'>;
@@ -8,6 +8,11 @@ import {
import type { MenuSubProps } from '../menu.types'; import type { MenuSubProps } from '../menu.types';
export type SubMenuContent = { export type SubMenuContent = {
/**
* Customize submenu's title
* @default "Back"
*/
title?: string;
items: ReactNode; items: ReactNode;
contentOptions?: MenuSubProps['subContentOptions']; contentOptions?: MenuSubProps['subContentOptions'];
}; };
@@ -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 };
};
@@ -134,9 +134,9 @@ export const MobileMenu = ({
className={styles.backButton} className={styles.backButton}
prefix={<ArrowLeftSmallIcon />} prefix={<ArrowLeftSmallIcon />}
onClick={() => setSubMenus(prev => prev.slice(0, index))} 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']()}
</Button> </Button>
{sub.items} {sub.items}
@@ -7,19 +7,20 @@ import { useMenuItem } from '../use-menu-item';
import { MobileMenuContext } from './context'; import { MobileMenuContext } from './context';
export const MobileMenuSub = ({ export const MobileMenuSub = ({
title,
children: propsChildren, children: propsChildren,
items, items,
triggerOptions, triggerOptions,
subContentOptions: contentOptions = {}, subContentOptions: contentOptions = {},
}: MenuSubProps) => { }: MenuSubProps & { title?: string }) => {
const { const {
className, className,
children, children,
otherProps: { onClick, ...otherTriggerOptions }, otherProps: { onClick, ...otherTriggerOptions },
} = useMenuItem({ } = useMenuItem({
...triggerOptions,
children: propsChildren, children: propsChildren,
suffixIcon: <ArrowRightSmallPlusIcon />, suffixIcon: <ArrowRightSmallPlusIcon />,
...triggerOptions,
}); });
return ( return (
@@ -27,6 +28,7 @@ export const MobileMenuSub = ({
onClick={onClick} onClick={onClick}
items={items} items={items}
subContentOptions={contentOptions} subContentOptions={contentOptions}
title={title}
> >
<div className={className} {...otherTriggerOptions}> <div className={className} {...otherTriggerOptions}>
{children} {children}
@@ -36,19 +38,23 @@ export const MobileMenuSub = ({
}; };
export const MobileMenuSubRaw = ({ export const MobileMenuSubRaw = ({
title,
onClick, onClick,
children, children,
items, items,
subContentOptions: contentOptions = {}, subContentOptions: contentOptions = {},
}: MenuSubProps & { onClick?: (e: MouseEvent<HTMLDivElement>) => void }) => { }: MenuSubProps & {
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
title?: string;
}) => {
const { setSubMenus } = useContext(MobileMenuContext); const { setSubMenus } = useContext(MobileMenuContext);
const onItemClick = useCallback( const onItemClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
onClick?.(e); onClick?.(e);
setSubMenus(prev => [...prev, { items, contentOptions }]); setSubMenus(prev => [...prev, { items, contentOptions, title }]);
}, },
[contentOptions, items, onClick, setSubMenus] [contentOptions, items, onClick, setSubMenus, title]
); );
return <Slot onClick={onItemClick}>{children}</Slot>; return <Slot onClick={onItemClick}>{children}</Slot>;
@@ -40,7 +40,7 @@ export const WorkspaceDeleteModal = ({
confirmButtonOptions={{ confirmButtonOptions={{
variant: 'error', variant: 'error',
disabled: !allowDelete, disabled: !allowDelete,
['data-testid' as string]: 'delete-workspace-confirm-button', 'data-testid': 'delete-workspace-confirm-button',
}} }}
{...props} {...props}
> >
@@ -127,7 +127,7 @@ export const Export = ({ exportHandler, className, pageMode }: ExportProps) => {
triggerOptions={{ triggerOptions={{
className: transitionStyle, className: transitionStyle,
prefixIcon: <ExportIcon />, prefixIcon: <ExportIcon />,
['data-testid' as string]: 'export-menu', 'data-testid': 'export-menu',
}} }}
subOptions={{ subOptions={{
onOpenChange: handleExportMenuOpenChange, onOpenChange: handleExportMenuOpenChange,
@@ -213,7 +213,7 @@ export const Snapshot = ({ className }: SnapshotProps) => {
triggerOptions={{ triggerOptions={{
className: transitionStyle, className: transitionStyle,
prefixIcon: <ToneIcon />, prefixIcon: <ToneIcon />,
['data-testid' as string]: 'snapshot-menu', 'data-testid': 'snapshot-menu',
}} }}
subOptions={{}} subOptions={{}}
> >
@@ -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 (
<RenameSubMenu
title={title || t['com.affine.m.explorer.collection.rename-menu-title']()}
text={text || t['com.affine.m.explorer.collection.rename']()}
{...props}
/>
);
};
const CollectionDesc = () => {
const t = useI18n();
return t['com.affine.collection.emptyCollectionDescription']();
};
export const CollectionRenameDialog = ({
title,
confirmText,
...props
}: RenameDialogProps) => {
return (
<RenameDialog
title={title}
confirmText={confirmText}
{...props}
descRenderer={CollectionDesc}
/>
);
};
@@ -52,23 +52,6 @@ export const ExplorerCollectionNode = ({
const collection = useLiveData(collectionService.collection$(collectionId)); 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(() => { const handleOpenCollapsed = useCallback(() => {
setCollapsed(false); setCollapsed(false);
}, []); }, []);
@@ -105,7 +88,7 @@ export const ExplorerCollectionNode = ({
return [...additionalOperations, ...collectionOperations]; return [...additionalOperations, ...collectionOperations];
} }
return collectionOperations; return collectionOperations;
}, [collectionOperations, additionalOperations]); }, [additionalOperations, collectionOperations]);
if (!collection) { if (!collection) {
return null; return null;
@@ -115,12 +98,10 @@ export const ExplorerCollectionNode = ({
<ExplorerTreeNode <ExplorerTreeNode
icon={CollectionIcon} icon={CollectionIcon}
name={collection.name || t['Untitled']()} name={collection.name || t['Untitled']()}
renameable
collapsed={collapsed} collapsed={collapsed}
setCollapsed={setCollapsed} setCollapsed={setCollapsed}
to={`/collection/${collection.id}`} to={`/collection/${collection.id}`}
active={active} active={active}
onRename={handleRename}
operations={finalOperations} operations={finalOperations}
data-testid={`explorer-collection-${collectionId}`} data-testid={`explorer-collection-${collectionId}`}
> >
@@ -2,6 +2,7 @@ import {
IconButton, IconButton,
MenuItem, MenuItem,
MenuSeparator, MenuSeparator,
notify,
useConfirmModal, useConfirmModal,
} from '@affine/component'; } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
@@ -28,6 +29,8 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { CollectionRenameSubMenu } from './dialog';
export const useExplorerCollectionNodeOperations = ( export const useExplorerCollectionNodeOperations = (
collectionId: string, collectionId: string,
onOpenCollapsed: () => void, onOpenCollapsed: () => void,
@@ -113,6 +116,24 @@ export const useExplorerCollectionNodeOperations = (
onOpenEdit(); onOpenEdit();
}, [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( return useMemo(
() => ({ () => ({
favorite, favorite,
@@ -122,6 +143,7 @@ export const useExplorerCollectionNodeOperations = (
handleOpenInSplitView, handleOpenInSplitView,
handleShowEdit, handleShowEdit,
handleToggleFavoriteCollection, handleToggleFavoriteCollection,
handleRename,
}), }),
[ [
favorite, favorite,
@@ -129,6 +151,7 @@ export const useExplorerCollectionNodeOperations = (
handleDeleteCollection, handleDeleteCollection,
handleOpenInNewTab, handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleRename,
handleShowEdit, handleShowEdit,
handleToggleFavoriteCollection, handleToggleFavoriteCollection,
] ]
@@ -154,6 +177,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
handleOpenInSplitView, handleOpenInSplitView,
handleShowEdit, handleShowEdit,
handleToggleFavoriteCollection, handleToggleFavoriteCollection,
handleRename,
} = useExplorerCollectionNodeOperations( } = useExplorerCollectionNodeOperations(
collectionId, collectionId,
onOpenCollapsed, onOpenCollapsed,
@@ -177,6 +201,14 @@ export const useExplorerCollectionNodeOperationsMenu = (
</IconButton> </IconButton>
), ),
}, },
{
index: 10,
view: <CollectionRenameSubMenu onConfirm={handleRename} />,
},
{
index: 11,
view: <MenuSeparator />,
},
{ {
index: 99, index: 99,
view: ( view: (
@@ -256,6 +288,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
handleDeleteCollection, handleDeleteCollection,
handleOpenInNewTab, handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleRename,
handleShowEdit, handleShowEdit,
handleToggleFavoriteCollection, handleToggleFavoriteCollection,
t, t,
@@ -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 (
<RenameSubMenu
title={title || t['com.affine.m.explorer.doc.rename']()}
text={text || t['com.affine.m.explorer.doc.rename']()}
{...props}
/>
);
};
@@ -1,11 +1,9 @@
import { Loading } from '@affine/component'; 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 { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocInfoService } from '@affine/core/modules/doc-info'; import { DocInfoService } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search'; import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { NodeOperation } from '@affine/core/modules/explorer'; import type { NodeOperation } from '@affine/core/modules/explorer';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { import {
DocsService, DocsService,
FeatureFlagService, FeatureFlagService,
@@ -92,14 +90,6 @@ export const ExplorerDocNode = ({
); );
}, [indexerLoading]); }, [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 docInfoModal = useService(DocInfoService).modal;
const option = useMemo( const option = useMemo(
() => ({ () => ({
@@ -126,7 +116,6 @@ export const ExplorerDocNode = ({
<ExplorerTreeNode <ExplorerTreeNode
icon={Icon} icon={Icon}
name={t.t(docTitle)} name={t.t(docTitle)}
renameable
extractEmojiAsIcon={enableEmojiIcon} extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed} collapsed={collapsed}
setCollapsed={setCollapsed} setCollapsed={setCollapsed}
@@ -140,7 +129,6 @@ export const ExplorerDocNode = ({
</div> </div>
) )
} }
onRename={handleRename}
operations={finalOperations} operations={finalOperations}
data-testid={`explorer-doc-${docId}`} data-testid={`explorer-doc-${docId}`}
> >
@@ -33,6 +33,8 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { DocRenameSubMenu } from './dialog';
export const useExplorerDocNodeOperations = ( export const useExplorerDocNodeOperations = (
docId: string, docId: string,
options: { options: {
@@ -135,6 +137,14 @@ export const useExplorerDocNodeOperations = (
}); });
}, [docId, compatibleFavoriteItemsAdapter]); }, [docId, compatibleFavoriteItemsAdapter]);
const handleRename = useAsyncCallback(
async (newName: string) => {
await docsService.changeDocTitle(docId, newName);
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
},
[docId, docsService]
);
return useMemo( return useMemo(
() => ({ () => ({
favorite, favorite,
@@ -145,6 +155,7 @@ export const useExplorerDocNodeOperations = (
handleOpenInNewTab, handleOpenInNewTab,
handleMoveToTrash, handleMoveToTrash,
handleOpenInfoModal, handleOpenInfoModal,
handleRename,
}), }),
[ [
favorite, favorite,
@@ -154,6 +165,7 @@ export const useExplorerDocNodeOperations = (
handleOpenInNewTab, handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleOpenInfoModal, handleOpenInfoModal,
handleRename,
handleToggleFavoriteDoc, handleToggleFavoriteDoc,
] ]
); );
@@ -177,8 +189,12 @@ export const useExplorerDocNodeOperationsMenu = (
handleOpenInNewTab, handleOpenInNewTab,
handleMoveToTrash, handleMoveToTrash,
handleOpenInfoModal, handleOpenInfoModal,
handleRename,
} = useExplorerDocNodeOperations(docId, options); } = useExplorerDocNodeOperations(docId, options);
const docService = useService(DocsService);
const docRecord = useLiveData(docService.list.doc$(docId));
const title = useLiveData(docRecord?.title$);
const enableMultiView = useLiveData( const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$ featureFlagService.flags.enable_multi_view.$
); );
@@ -197,6 +213,14 @@ export const useExplorerDocNodeOperationsMenu = (
/> />
), ),
}, },
{
index: 10,
view: <DocRenameSubMenu onConfirm={handleRename} initialName={title} />,
},
{
index: 11,
view: <MenuSeparator />,
},
{ {
index: 50, index: 50,
view: ( view: (
@@ -289,8 +313,10 @@ export const useExplorerDocNodeOperationsMenu = (
handleOpenInNewTab, handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleOpenInfoModal, handleOpenInfoModal,
handleRename,
handleToggleFavoriteDoc, handleToggleFavoriteDoc,
t, t,
title,
] ]
); );
}; };
@@ -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 || <EditIcon />;
const text = propsText || title;
return <RenameSubMenu title={title} icon={icon} text={text} {...props} />;
};
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 <RenameDialog title={title} confirmText={confirmText} {...props} />;
};
@@ -47,14 +47,13 @@ import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerCollectionNode } from '../collection'; import { ExplorerCollectionNode } from '../collection';
import { ExplorerDocNode } from '../doc'; import { ExplorerDocNode } from '../doc';
import { ExplorerTagNode } from '../tag'; import { ExplorerTagNode } from '../tag';
import { FolderCreateTip, FolderRenameSubMenu } from './dialog';
import { FavoriteFolderOperation } from './operations'; import { FavoriteFolderOperation } from './operations';
export const ExplorerFolderNode = ({ export const ExplorerFolderNode = ({
nodeId, nodeId,
defaultRenaming,
operations, operations,
}: { }: {
defaultRenaming?: boolean;
nodeId: string; nodeId: string;
operations?: operations?:
| NodeOperation[] | NodeOperation[]
@@ -83,11 +82,7 @@ export const ExplorerFolderNode = ({
if (type === 'folder') { if (type === 'folder') {
return ( return (
<ExplorerFolderNodeFolder <ExplorerFolderNodeFolder node={node} operations={additionalOperations} />
node={node}
defaultRenaming={defaultRenaming}
operations={additionalOperations}
/>
); );
} }
if (!data) return null; if (!data) return null;
@@ -123,10 +118,8 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
const ExplorerFolderNodeFolder = ({ const ExplorerFolderNodeFolder = ({
node, node,
defaultRenaming,
operations: additionalOperations, operations: additionalOperations,
}: { }: {
defaultRenaming?: boolean;
node: FolderNode; node: FolderNode;
operations?: NodeOperation[]; operations?: NodeOperation[];
}) => { }) => {
@@ -144,7 +137,6 @@ const ExplorerFolderNodeFolder = ({
featureFlagService.flags.enable_emoji_folder_icon.$ featureFlagService.flags.enable_emoji_folder_icon.$
); );
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const { createPage } = usePageHelper( const { createPage } = usePageHelper(
workspaceService.workspace.docCollection workspaceService.workspace.docCollection
@@ -182,15 +174,14 @@ const ExplorerFolderNodeFolder = ({
setCollapsed(false); setCollapsed(false);
}, [createPage, node]); }, [createPage, node]);
const handleCreateSubfolder = useCallback(() => { const handleCreateSubfolder = useCallback(
const newFolderId = node.createFolder( (name: string) => {
t['com.affine.rootAppSidebar.organize.new-folders'](), node.createFolder(name, node.indexAt('before'));
node.indexAt('before') track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
); setCollapsed(false);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); },
setCollapsed(false); [node]
setNewFolderId(newFolderId); );
}, [node, t]);
const handleAddToFolder = useCallback( const handleAddToFolder = useCallback(
(type: 'doc' | 'collection' | 'tag') => { (type: 'doc' | 'collection' | 'tag') => {
@@ -237,6 +228,13 @@ const ExplorerFolderNodeFolder = ({
] ]
); );
const createSubTipRenderer = useCallback(
({ input }: { input: string }) => {
return <FolderCreateTip input={input} parentName={name} />;
},
[name]
);
const folderOperations = useMemo(() => { const folderOperations = useMemo(() => {
return [ return [
{ {
@@ -254,12 +252,39 @@ const ExplorerFolderNodeFolder = ({
</IconButton> </IconButton>
), ),
}, },
{
index: 98,
view: (
<FolderRenameSubMenu
initialName={name}
onConfirm={handleRename}
menuProps={{
triggerOptions: { 'data-testid': 'rename-folder' },
}}
/>
),
},
{
index: 99,
view: <MenuSeparator />,
},
{ {
index: 100, index: 100,
view: ( view: (
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}> <FolderRenameSubMenu
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()} text={t[
</MenuItem> 'com.affine.rootAppSidebar.organize.folder.create-subfolder'
]()}
title={t[
'com.affine.rootAppSidebar.organize.folder.create-subfolder'
]()}
onConfirm={handleCreateSubfolder}
descRenderer={createSubTipRenderer}
icon={<FolderIcon />}
menuProps={{
triggerOptions: { 'data-testid': 'create-subfolder' },
}}
/>
), ),
}, },
{ {
@@ -327,11 +352,14 @@ const ExplorerFolderNodeFolder = ({
}, },
]; ];
}, [ }, [
createSubTipRenderer,
handleAddToFolder, handleAddToFolder,
handleCreateSubfolder, handleCreateSubfolder,
handleDelete, handleDelete,
handleNewDoc, handleNewDoc,
node, handleRename,
name,
node.id,
t, t,
]); ]);
@@ -370,7 +398,6 @@ const ExplorerFolderNodeFolder = ({
const handleCollapsedChange = useCallback((collapsed: boolean) => { const handleCollapsedChange = useCallback((collapsed: boolean) => {
if (collapsed) { if (collapsed) {
setNewFolderId(null); // reset new folder id to clear the renaming state
setCollapsed(true); setCollapsed(true);
} else { } else {
setCollapsed(false); setCollapsed(false);
@@ -381,26 +408,25 @@ const ExplorerFolderNodeFolder = ({
<ExplorerTreeNode <ExplorerTreeNode
icon={ExplorerFolderIcon} icon={ExplorerFolderIcon}
name={name} name={name}
defaultRenaming={defaultRenaming}
renameable
extractEmojiAsIcon={enableEmojiIcon} extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed} collapsed={collapsed}
setCollapsed={handleCollapsedChange} setCollapsed={handleCollapsedChange}
onRename={handleRename}
operations={finalOperations} operations={finalOperations}
data-testid={`explorer-folder-${node.id}`} data-testid={`explorer-folder-${node.id}`}
aria-label={name}
data-role="explorer-folder"
> >
{children.map(child => ( {children.map(child => (
<ExplorerFolderNode <ExplorerFolderNode
key={child.id} key={child.id}
nodeId={child.id as string} nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
operations={childrenOperations} operations={childrenOperations}
/> />
))} ))}
<AddItemPlaceholder <AddItemPlaceholder
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()} label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
onClick={() => handleAddToFolder('doc')} onClick={() => handleAddToFolder('doc')}
data-testid="new-folder-in-folder-button"
/> />
</ExplorerTreeNode> </ExplorerTreeNode>
); );
@@ -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,
},
},
});
@@ -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<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
enableAnimation?: boolean;
}>({
color: '',
setColor: () => {},
colors: [],
show: false,
setShow: () => {},
});
const ColorPickerTrigger = () => {
const { color, show, setShow } = useContext(TagColorContext);
return (
<div
data-testid="tag-color-picker-trigger"
className={styles.colorTrigger}
style={{ color }}
data-active={show}
onClick={() => setShow(prev => !prev)}
/>
);
};
const ColorPickerSelect = () => {
const {
enableAnimation,
colors,
color: current,
setColor,
show,
} = useContext(TagColorContext);
if (!show && !enableAnimation) return null;
return (
<div
data-testid="tag-color-picker-select"
className={styles.colorsRow}
data-active={show}
data-enable-fold={enableAnimation || undefined}
>
{colors.map(color => (
<div
key={color}
aria-checked={color === current}
onClick={() => setColor(color)}
className={styles.colorDot}
style={{ color }}
data-color={color}
/>
))}
</div>
);
};
interface TagRenameContentProps extends Omit<RenameContentProps, 'onConfirm'> {
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 (
<TagColorContext.Provider
value={{ colors, color, setColor, show, setShow, enableAnimation }}
>
<RenameContent
inputPrefixRenderer={ColorPickerTrigger}
inputBelowRenderer={ColorPickerSelect}
onConfirm={handleConfirm}
{...props}
/>
</TagColorContext.Provider>
);
};
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 (
<RenameDialog
open={open}
onOpenChange={onOpenChange}
inputPrefixRenderer={ColorPickerTrigger}
title={title}
confirmText={confirmText}
>
<TagRenameContent {...props} />
</RenameDialog>
);
};
interface TagRenameSubMenuProps {
tagId?: string;
title?: string;
icon?: ReactNode;
text?: string;
onConfirm?: (name: string, color: string) => void;
menuProps?: Partial<MenuSubProps>;
}
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 (
<RenameSubMenu
title={title ?? t['com.affine.m.explorer.tag.rename-menu-title']()}
icon={icon}
text={text}
menuProps={menuProps}
>
<TagRenameContent
initialName={tagName}
initialColor={tagColor}
onConfirm={handleCloseAndConfirm}
/>
</RenameSubMenu>
);
};
@@ -2,7 +2,6 @@ import type { NodeOperation } from '@affine/core/modules/explorer';
import type { Tag } from '@affine/core/modules/tag'; import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { import {
GlobalContextService, GlobalContextService,
useLiveData, useLiveData,
@@ -23,10 +22,8 @@ import * as styles from './styles.css';
export const ExplorerTagNode = ({ export const ExplorerTagNode = ({
tagId, tagId,
operations: additionalOperations, operations: additionalOperations,
defaultRenaming,
}: { }: {
tagId: string; tagId: string;
defaultRenaming?: boolean;
operations?: NodeOperation[]; operations?: NodeOperation[];
}) => { }) => {
const t = useI18n(); const t = useI18n();
@@ -47,6 +44,7 @@ export const ExplorerTagNode = ({
return ( return (
<div className={clsx(styles.tagIconContainer, className)}> <div className={clsx(styles.tagIconContainer, className)}>
<div <div
data-testid="explorer-tag-icon-dot"
className={styles.tagIcon} className={styles.tagIcon}
style={{ style={{
backgroundColor: tagColor, backgroundColor: tagColor,
@@ -58,18 +56,6 @@ export const ExplorerTagNode = ({
[tagColor] [tagColor]
); );
const handleRename = useCallback(
(newName: string) => {
if (tagRecord && tagRecord.value$.value !== newName) {
tagRecord.rename(newName);
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'tag',
});
}
},
[tagRecord]
);
const option = useMemo( const option = useMemo(
() => ({ () => ({
openNodeCollapsed: () => setCollapsed(false), openNodeCollapsed: () => setCollapsed(false),
@@ -94,15 +80,14 @@ export const ExplorerTagNode = ({
<ExplorerTreeNode <ExplorerTreeNode
icon={Icon} icon={Icon}
name={tagName || t['Untitled']()} name={tagName || t['Untitled']()}
renameable
collapsed={collapsed} collapsed={collapsed}
setCollapsed={setCollapsed} setCollapsed={setCollapsed}
to={`/tag/${tagId}`} to={`/tag/${tagId}`}
active={active} active={active}
defaultRenaming={defaultRenaming}
onRename={handleRename}
operations={finalOperations} operations={finalOperations}
data-testid={`explorer-tag-${tagId}`} data-testid={`explorer-tag-${tagId}`}
aria-label={tagName}
data-role="explorer-tag"
> >
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} /> <ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
</ExplorerTreeNode> </ExplorerTreeNode>
@@ -7,12 +7,7 @@ import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
DeleteIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { import {
DocsService, DocsService,
FeatureFlagService, FeatureFlagService,
@@ -23,6 +18,8 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { TagRenameSubMenu } from './dialog';
export const useExplorerTagNodeOperations = ( export const useExplorerTagNodeOperations = (
tagId: string, tagId: string,
{ {
@@ -86,6 +83,37 @@ export const useExplorerTagNodeOperations = (
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' }); track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
}, [tagId, workbenchService]); }, [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( return useMemo(
() => ({ () => ({
favorite, favorite,
@@ -94,13 +122,19 @@ export const useExplorerTagNodeOperations = (
handleOpenInSplitView, handleOpenInSplitView,
handleToggleFavoriteTag, handleToggleFavoriteTag,
handleOpenInNewTab, handleOpenInNewTab,
handleRename,
handleChangeColor,
handleChangeNameOrColor,
}), }),
[ [
favorite, favorite,
handleChangeColor,
handleChangeNameOrColor,
handleMoveToTrash, handleMoveToTrash,
handleNewDoc, handleNewDoc,
handleOpenInNewTab, handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleRename,
handleToggleFavoriteTag, handleToggleFavoriteTag,
] ]
); );
@@ -122,7 +156,7 @@ export const useExplorerTagNodeOperationsMenu = (
handleMoveToTrash, handleMoveToTrash,
handleOpenInSplitView, handleOpenInSplitView,
handleToggleFavoriteTag, handleToggleFavoriteTag,
handleOpenInNewTab, handleChangeNameOrColor,
} = useExplorerTagNodeOperations(tagId, option); } = useExplorerTagNodeOperations(tagId, option);
return useMemo( return useMemo(
@@ -131,21 +165,19 @@ export const useExplorerTagNodeOperationsMenu = (
index: 0, index: 0,
inline: true, inline: true,
view: ( view: (
<IconButton <IconButton size="16" onClick={handleNewDoc}>
size="16"
onClick={handleNewDoc}
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
>
<PlusIcon /> <PlusIcon />
</IconButton> </IconButton>
), ),
}, },
{ {
index: 50, index: 10,
view: ( view: (
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}> <TagRenameSubMenu
{t['com.affine.workbench.tab.page-menu-open']()} onConfirm={handleChangeNameOrColor}
</MenuItem> tagId={tagId}
menuProps={{ triggerOptions: { 'data-testid': 'rename-tag' } }}
/>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron && enableMultiView
@@ -196,12 +228,13 @@ export const useExplorerTagNodeOperationsMenu = (
[ [
enableMultiView, enableMultiView,
favorite, favorite,
handleChangeNameOrColor,
handleMoveToTrash, handleMoveToTrash,
handleNewDoc, handleNewDoc,
handleOpenInNewTab,
handleOpenInSplitView, handleOpenInSplitView,
handleToggleFavoriteTag, handleToggleFavoriteTag,
t, t,
tagId,
] ]
); );
}; };
@@ -1,4 +1,3 @@
import { useEditCollectionName } from '@affine/core/components/page-list';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager'; import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { CollectionService } from '@affine/core/modules/collection'; import { CollectionService } from '@affine/core/modules/collection';
import { ExplorerService } from '@affine/core/modules/explorer'; import { ExplorerService } from '@affine/core/modules/explorer';
@@ -8,11 +7,12 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section'; import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerCollectionNode } from '../../nodes/collection'; import { ExplorerCollectionNode } from '../../nodes/collection';
import { CollectionRenameDialog } from '../../nodes/collection/dialog';
export const ExplorerCollections = () => { export const ExplorerCollections = () => {
const t = useI18n(); const t = useI18n();
@@ -23,31 +23,22 @@ export const ExplorerCollections = () => {
}); });
const explorerSection = explorerService.sections.collections; const explorerSection = explorerService.sections.collections;
const collections = useLiveData(collectionService.collections$); const collections = useLiveData(collectionService.collections$);
const { open: openCreateCollectionModel } = useEditCollectionName({ const [showCreateCollectionModal, setShowCreateCollectionModal] =
title: t['com.affine.editCollection.createCollection'](), useState(false);
showTips: true,
});
const handleCreateCollection = useCallback(() => { const handleCreateCollection = useCallback(
openCreateCollectionModel('') (name: string) => {
.then(name => { setShowCreateCollectionModal(false);
const id = nanoid(); const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name })); collectionService.addCollection(createEmptyCollection(id, { name }));
track.$.navigationPanel.organize.createOrganizeItem({ track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection', type: 'collection',
});
workbenchService.workbench.openCollection(id);
explorerSection.setCollapsed(false);
})
.catch(err => {
console.error(err);
}); });
}, [ workbenchService.workbench.openCollection(id);
collectionService, explorerSection.setCollapsed(false);
explorerSection, },
openCreateCollectionModel, [collectionService, explorerSection, workbenchService.workbench]
workbenchService.workbench, );
]);
return ( return (
<CollapsibleSection <CollapsibleSection
@@ -65,7 +56,13 @@ export const ExplorerCollections = () => {
<AddItemPlaceholder <AddItemPlaceholder
data-testid="explorer-bar-add-collection-button" data-testid="explorer-bar-add-collection-button"
label={t['com.affine.rootAppSidebar.collection.new']()} label={t['com.affine.rootAppSidebar.collection.new']()}
onClick={handleCreateCollection} onClick={() => setShowCreateCollectionModal(true)}
/>
<CollectionRenameDialog
title={t['com.affine.m.explorer.collection.new-dialog-title']()}
open={showCreateCollectionModal}
onOpenChange={setShowCreateCollectionModal}
onConfirm={handleCreateCollection}
/> />
</ExplorerTreeRoot> </ExplorerTreeRoot>
</CollapsibleSection> </CollapsibleSection>
@@ -7,11 +7,12 @@ import { OrganizeService } from '@affine/core/modules/organize';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra'; 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 { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section'; import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerFolderNode } from '../../nodes/folder'; import { ExplorerFolderNode } from '../../nodes/folder';
import { FolderCreateTip, FolderRenameDialog } from '../../nodes/folder/dialog';
export const ExplorerOrganize = () => { export const ExplorerOrganize = () => {
const { organizeService, explorerService } = useServices({ const { organizeService, explorerService } = useServices({
@@ -19,8 +20,7 @@ export const ExplorerOrganize = () => {
ExplorerService, ExplorerService,
}); });
const explorerSection = explorerService.sections.organize; const explorerSection = explorerService.sections.organize;
const collapsed = useLiveData(explorerSection.collapsed$); const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false);
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const t = useI18n(); const t = useI18n();
@@ -30,20 +30,18 @@ export const ExplorerOrganize = () => {
const folders = useLiveData(rootFolder.sortedChildren$); const folders = useLiveData(rootFolder.sortedChildren$);
const isLoading = useLiveData(folderTree.isLoading$); const isLoading = useLiveData(folderTree.isLoading$);
const handleCreateFolder = useCallback(() => { const handleCreateFolder = useCallback(
const newFolderId = rootFolder.createFolder( (name: string) => {
'New Folder', const newFolderId = rootFolder.createFolder(
rootFolder.indexAt('before') name,
); rootFolder.indexAt('before')
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); );
setNewFolderId(newFolderId); track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
explorerSection.setCollapsed(false); explorerSection.setCollapsed(false);
return newFolderId; return newFolderId;
}, [explorerSection, rootFolder]); },
[explorerSection, rootFolder]
useEffect(() => { );
if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state
}, [collapsed]);
return ( return (
<CollapsibleSection <CollapsibleSection
@@ -53,18 +51,20 @@ export const ExplorerOrganize = () => {
{/* TODO(@CatsJuice): Organize loading UI */} {/* TODO(@CatsJuice): Organize loading UI */}
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}> <ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
{folders.map(child => ( {folders.map(child => (
<ExplorerFolderNode <ExplorerFolderNode key={child.id} nodeId={child.id as string} />
key={child.id}
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
/>
))} ))}
<AddItemPlaceholder <AddItemPlaceholder
data-testid="explorer-bar-add-organize-button" data-testid="explorer-bar-add-organize-button"
label={t['com.affine.rootAppSidebar.organize.add-folder']()} label={t['com.affine.rootAppSidebar.organize.add-folder']()}
onClick={handleCreateFolder} onClick={() => setOpenNewFolderDialog(true)}
/> />
</ExplorerTreeRoot> </ExplorerTreeRoot>
<FolderRenameDialog
open={openNewFolderDialog}
onConfirm={handleCreateFolder}
onOpenChange={setOpenNewFolderDialog}
descRenderer={FolderCreateTip}
/>
</CollapsibleSection> </CollapsibleSection>
); );
}; };
@@ -1,15 +1,23 @@
import { ExplorerService } from '@affine/core/modules/explorer'; import { ExplorerService } from '@affine/core/modules/explorer';
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; 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 { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra'; 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 { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section'; import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerTagNode } from '../../nodes/tag'; 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 = () => { export const ExplorerTags = () => {
const { tagService, explorerService } = useServices({ const { tagService, explorerService } = useServices({
@@ -17,25 +25,20 @@ export const ExplorerTags = () => {
ExplorerService, ExplorerService,
}); });
const explorerSection = explorerService.sections.tags; const explorerSection = explorerService.sections.tags;
const collapsed = useLiveData(explorerSection.collapsed$);
const [createdTag, setCreatedTag] = useState<Tag | null>(null);
const tags = useLiveData(tagService.tagList.tags$); const tags = useLiveData(tagService.tagList.tags$);
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
const t = useI18n(); const t = useI18n();
const handleCreateNewFavoriteDoc = useCallback(() => { const handleNewTag = useCallback(
const newTags = tagService.tagList.createTag( (name: string, color: string) => {
t['com.affine.rootAppSidebar.tags.new-tag'](), setShowNewTagDialog(false);
tagService.randomTagColor() tagService.tagList.createTag(name, color);
); track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
setCreatedTag(newTags); explorerSection.setCollapsed(false);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' }); },
explorerSection.setCollapsed(false); [explorerSection, tagService]
}, [explorerSection, t, tagService]); );
useEffect(() => {
if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state
}, [collapsed]);
return ( return (
<CollapsibleSection <CollapsibleSection
@@ -44,19 +47,22 @@ export const ExplorerTags = () => {
> >
<ExplorerTreeRoot> <ExplorerTreeRoot>
{tags.map(tag => ( {tags.map(tag => (
<ExplorerTagNode <ExplorerTagNode key={tag.id} tagId={tag.id} />
key={tag.id}
tagId={tag.id}
defaultRenaming={createdTag?.id === tag.id}
/>
))} ))}
<AddItemPlaceholder <AddItemPlaceholder
data-testid="explorer-bar-add-favorite-button" data-testid="explorer-add-tag-button"
onClick={handleCreateNewFavoriteDoc} onClick={() => setShowNewTagDialog(true)}
label={t[ label={t[
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip' 'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
]()} ]()}
/> />
<TagRenameDialog
open={showNewTagDialog}
onOpenChange={setShowNewTagDialog}
onConfirm={handleNewTag}
enableAnimation
descRenderer={TagDesc}
/>
</ExplorerTreeRoot> </ExplorerTreeRoot>
</CollapsibleSection> </CollapsibleSection>
); );
@@ -1,17 +1,10 @@
import { MenuItem, MobileMenu } from '@affine/component'; import { MobileMenu } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal'; import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type {
BaseExplorerTreeNodeProps,
NodeOperation,
} from '@affine/core/modules/explorer';
import { ExplorerTreeContext } from '@affine/core/modules/explorer'; import { ExplorerTreeContext } from '@affine/core/modules/explorer';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
import { extractEmojiIcon } from '@affine/core/utils'; import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n'; import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import { import {
Fragment, Fragment,
@@ -33,9 +26,6 @@ export const ExplorerTreeNode = ({
onClick, onClick,
to, to,
active, active,
defaultRenaming,
renameable,
onRename,
disabled, disabled,
collapsed, collapsed,
extractEmojiAsIcon, extractEmojiAsIcon,
@@ -47,18 +37,13 @@ export const ExplorerTreeNode = ({
linkComponent: LinkComponent = WorkbenchLink, linkComponent: LinkComponent = WorkbenchLink,
...otherProps ...otherProps
}: ExplorerTreeNodeProps) => { }: ExplorerTreeNodeProps) => {
const t = useI18n();
const context = useContext(ExplorerTreeContext); const context = useContext(ExplorerTreeContext);
const level = context?.level ?? 0; const level = context?.level ?? 0;
// If no onClick or to is provided, clicking on the node will toggle the collapse state // If no onClick or to is provided, clicking on the node will toggle the collapse state
const clickForCollapse = !onClick && !to && !disabled; const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0); const [childCount, setChildCount] = useState(0);
const [renaming, setRenaming] = useState(defaultRenaming);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const appSidebarService = useService(AppSidebarService).sidebar;
const sidebarWidth = useLiveData(appSidebarService.width$);
const { emoji, name } = useMemo(() => { const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) { if (!extractEmojiAsIcon || !rawName) {
return { return {
@@ -73,39 +58,13 @@ export const ExplorerTreeNode = ({
}; };
}, [extractEmojiAsIcon, rawName]); }, [extractEmojiAsIcon, rawName]);
const presetOperations = useMemo(
() =>
(
[
renameable
? {
index: 0,
view: (
<MenuItem
key={'explorer-tree-rename'}
type={'default'}
prefixIcon={<EditIcon />}
onClick={() => setRenaming(true)}
>
{t['com.affine.menu.rename']()}
</MenuItem>
),
}
: null,
] as (NodeOperation | null)[]
).filter((t): t is NodeOperation => t !== null),
[renameable, t]
);
const { menuOperations } = useMemo(() => { const { menuOperations } = useMemo(() => {
const sorted = [...presetOperations, ...operations].sort( const sorted = [...operations].sort((a, b) => a.index - b.index);
(a, b) => a.index - b.index
);
return { return {
menuOperations: sorted.filter(({ inline }) => !inline), menuOperations: sorted.filter(({ inline }) => !inline),
inlineOperations: sorted.filter(({ inline }) => !!inline), inlineOperations: sorted.filter(({ inline }) => !!inline),
}; };
}, [presetOperations, operations]); }, [operations]);
const contextValue = useMemo(() => { const contextValue = useMemo(() => {
return { return {
@@ -127,11 +86,6 @@ export const ExplorerTreeNode = ({
[collapsed, setCollapsed] [collapsed, setCollapsed]
); );
const handleRename = useCallback(
(newName: string) => onRename?.(newName),
[onRename]
);
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.defaultPrevented) { if (e.defaultPrevented) {
@@ -166,7 +120,7 @@ export const ExplorerTreeNode = ({
<Fragment key={index}>{view}</Fragment> <Fragment key={index}>{view}</Fragment>
))} ))}
> >
<div className={styles.iconContainer}> <div className={styles.iconContainer} data-testid="menu-trigger">
{emoji ?? (Icon && <Icon collapsed={collapsed} />)} {emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div> </div>
</MobileMenu> </MobileMenu>
@@ -193,18 +147,6 @@ export const ExplorerTreeNode = ({
data-collapsed={collapsed !== false} data-collapsed={collapsed !== false}
/> />
</div> </div>
{renameable && (
<RenameModal
open={!!renaming}
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={rawName ?? ''}
>
<div className={styles.itemRenameAnchor} />
</RenameModal>
)}
</div> </div>
); );
@@ -1,6 +1,7 @@
export * from './app-tabs'; export * from './app-tabs';
export * from './doc-card'; export * from './doc-card';
export * from './page-header'; export * from './page-header';
export * from './rename';
export * from './search-input'; export * from './search-input';
export * from './search-result'; export * from './search-result';
export * from './user-plan-tag'; export * from './user-plan-tag';
@@ -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,
});
@@ -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 (
<div>
<div className={styles.inputWrapper}>
{InputPrefixRenderer ? <InputPrefixRenderer input={value} /> : null}
<RowInput
autoFocus
className={clsx(styles.input, inputClassName)}
value={value}
onChange={setValue}
data-testid="rename-input"
{...restInputProps}
/>
</div>
{}
{InputBelowRenderer ? <InputBelowRenderer input={value} /> : null}
<div className={styles.desc}>
{DescRenderer ? (
<DescRenderer input={value} />
) : (
t['com.affine.m.rename-to']({ name: value })
)}
</div>
<div className={styles.doneWrapper}>
<Button
className={clsx(styles.done, confirmButtonClassName)}
onClick={handleDone}
disabled={!value}
variant="primary"
size="extraLarge"
data-testid="rename-confirm"
{...restConfirmButtonProps}
>
{confirmText}
</Button>
</div>
</div>
);
};
@@ -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'),
});
@@ -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 (
<Modal
width="100%"
open={open}
onOpenChange={onOpenChange}
withoutCloseButton
contentOptions={{
style: { minHeight: 0, padding: '12px 0', borderRadius: 22 },
}}
>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<IconButton size="24" icon={<CloseIcon />} onClick={close} />
</div>
{children ?? <RenameContent onConfirm={handleRename} {...props} />}
</Modal>
);
};
@@ -0,0 +1,4 @@
export * from './content';
export * from './dialog';
export * from './sub-menu';
export * from './type';
@@ -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 (
<MobileMenuSub
triggerOptions={{
prefixIcon: icon ?? <EditIcon />,
suffixIcon: null,
...triggerOptions,
}}
items={
children ?? (
<RenameContent
initialName={initialName}
onConfirm={handleRename}
{...props}
/>
)
}
title={title}
{...otherMenuProps}
>
{text ?? t['com.affine.m.explorer.folder.rename']()}
</MobileMenuSub>
);
};
@@ -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<RowInputProps, 'value' | 'onChange'>;
confirmButtonProps?: Omit<ButtonProps, 'onClick' | 'children'>;
confirmText?: string;
inputPrefixRenderer?: (props: { input: string }) => ReactNode;
descRenderer?: (props: { input: string }) => ReactNode;
inputBelowRenderer?: (props: { input: string }) => ReactNode;
}
export interface RenameSubMenuProps
extends PropsWithChildren<RenameContentProps> {
/** Submenu's title */
title?: string;
/** MenuItem.icon */
icon?: ReactNode;
/** MenuItem.text */
text?: string;
menuProps?: Partial<MenuSubProps>;
}
export interface RenameDialogProps
extends PropsWithChildren<RenameContentProps> {
open?: boolean;
onOpenChange?: (open: boolean) => void;
title?: string;
}
@@ -95,7 +95,7 @@ const NameWorkspaceContent = ({
variant: 'primary', variant: 'primary',
loading, loading,
disabled: !workspaceName, disabled: !workspaceName,
['data-testid' as string]: 'create-workspace-create-button', 'data-testid': 'create-workspace-create-button',
}} }}
closeButtonOptions={{ closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button', ['data-testid' as string]: 'create-workspace-close-button',
@@ -61,12 +61,9 @@ export interface BaseExplorerTreeNodeProps {
icon?: ExplorerTreeNodeIcon; icon?: ExplorerTreeNodeIcon;
children?: React.ReactNode; children?: React.ReactNode;
active?: boolean; active?: boolean;
defaultRenaming?: boolean;
extractEmojiAsIcon?: boolean; extractEmojiAsIcon?: boolean;
collapsed: boolean; collapsed: boolean;
setCollapsed: (collapsed: boolean) => void; setCollapsed: (collapsed: boolean) => void;
renameable?: boolean;
onRename?: (newName: string) => void;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
to?: To; to?: To;
@@ -81,6 +78,10 @@ export interface BaseExplorerTreeNodeProps {
} }
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps { interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
renameable?: boolean;
onRename?: (newName: string) => void;
defaultRenaming?: boolean;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop']; canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
reorderable?: boolean; reorderable?: boolean;
dndData?: AffineDNDData; dndData?: AffineDNDData;
@@ -18,5 +18,5 @@
"sv-SE": 5, "sv-SE": 5,
"ur": 3, "ur": 3,
"zh-Hans": 98, "zh-Hans": 98,
"zh-Hant": 96 "zh-Hant": 97
} }
+18 -1
View File
@@ -1352,5 +1352,22 @@
"system": "System", "system": "System",
"unnamed": "unnamed", "unnamed": "unnamed",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.", "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"
} }
@@ -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();
});
@@ -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);
});
+22 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable unicorn/prefer-dom-node-dataset */ /* 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) { export async function expandCollapsibleSection(page: Page, name: string) {
const divider = page.locator(`[data-collapsible]:has-text("${name}")`); 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) { export async function pageBack(page: Page) {
await page.getByTestId('page-header-back').tap(); 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;
}