feat(core): new empty states for doc/collection/tag (#8197)

AF-1329, AF-1330
This commit is contained in:
CatsJuice
2024-09-11 10:48:52 +00:00
parent f12655655e
commit b7d05d2078
49 changed files with 656 additions and 456 deletions
@@ -0,0 +1,30 @@
import { Button, type ButtonProps } from '@affine/component';
import clsx from 'clsx';
import {
actionButton,
actionContent,
mobileActionButton,
mobileActionContent,
} from './style.css';
export const ActionButton = ({
className,
contentClassName,
...props
}: ButtonProps) => {
return (
<Button
size="large"
className={clsx(
environment.isMobileEdition ? mobileActionButton : actionButton,
className
)}
contentClassName={clsx(
environment.isMobileEdition ? mobileActionContent : actionContent,
contentClassName
)}
{...props}
/>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,66 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { CollectionService } from '@affine/core/modules/collection';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useEditCollection } from '../../page-list';
import { ActionButton } from './action-button';
import collectionDetailDark from './assets/collection-detail.dark.png';
import collectionDetailLight from './assets/collection-detail.light.png';
import { EmptyLayout } from './layout';
import { actionGroup } from './style.css';
import type { UniversalEmptyProps } from './types';
export interface EmptyCollectionDetailProps extends UniversalEmptyProps {
collection: Collection;
}
export const EmptyCollectionDetail = ({
collection,
...props
}: EmptyCollectionDetailProps) => {
const t = useI18n();
return (
<EmptyLayout
illustrationLight={collectionDetailLight}
illustrationDark={collectionDetailDark}
title={t['com.affine.empty.collection-detail.title']()}
description={t['com.affine.empty.collection-detail.description']()}
action={
environment.isMobileEdition ? null : <Actions collection={collection} />
}
{...props}
/>
);
};
const Actions = ({ collection }: { collection: Collection }) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const { open } = useEditCollection();
const openAddDocs = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
collectionService.updateCollection(ret.id, () => ret);
}, [open, collection, collectionService]);
const openAddRules = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'rule');
collectionService.updateCollection(ret.id, () => ret);
}, [collection, open, collectionService]);
return (
<div className={actionGroup}>
<ActionButton prefix={<AllDocsIcon />} onClick={openAddDocs}>
{t['com.affine.empty.collection-detail.action.add-doc']()}
</ActionButton>
<ActionButton prefix={<FilterIcon />} onClick={openAddRules}>
{t['com.affine.empty.collection-detail.action.add-rule']()}
</ActionButton>
</div>
);
};
@@ -0,0 +1,60 @@
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection, useEditCollectionName } from '../../page-list';
import { ActionButton } from './action-button';
import collectionListDark from './assets/collection-list.dark.png';
import collectionListLight from './assets/collection-list.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export const EmptyCollections = (props: UniversalEmptyProps) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const currentWorkspace = useService(WorkspaceService).workspace;
const navigateHelper = useNavigateHelper();
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const showAction = true;
const handleCreateCollection = useCallback(() => {
open('')
.then(name => {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
navigateHelper.jumpToCollection(currentWorkspace.id, id);
})
.catch(err => {
console.error(err);
});
}, [collectionService, currentWorkspace, navigateHelper, open]);
return (
<EmptyLayout
illustrationLight={collectionListLight}
illustrationDark={collectionListDark}
title={t['com.affine.empty.collections.title']()}
description={t['com.affine.empty.collections.description']()}
action={
showAction ? (
<ActionButton
prefix={<ViewLayersIcon />}
onClick={handleCreateCollection}
>
{t['com.affine.empty.collections.action.new-collection']()}
</ActionButton>
) : null
}
{...props}
/>
);
};
@@ -0,0 +1,69 @@
import { TagService } from '@affine/core/modules/tag';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { ActionButton } from './action-button';
import docsIllustrationDark from './assets/docs.dark.png';
import docsIllustrationLight from './assets/docs.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export interface EmptyDocsProps extends UniversalEmptyProps {
type?: 'all' | 'trash';
/**
* Used for "New doc", if provided, new doc will be created with this tag.
*/
tagId?: string;
}
export const EmptyDocs = ({
type = 'all',
tagId,
...props
}: EmptyDocsProps) => {
const t = useI18n();
const tagService = useService(TagService);
const currentWorkspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const tag = useLiveData(tagService.tagList.tagByTagId$(tagId));
const showActionButton = type !== 'trash'; // && !environment.isMobileEdition;
const onCreate = useCallback(
(e: MouseEvent) => {
const doc = pageHelper.createPage(
undefined,
isNewTabTrigger(e) ? 'new-tab' : true
);
doc.load();
if (tag) tag.tag(doc.id);
},
[pageHelper, tag]
);
return (
<EmptyLayout
illustrationLight={docsIllustrationLight}
illustrationDark={docsIllustrationDark}
title={t['com.affine.empty.docs.title']()}
description={
type === 'trash'
? t['com.affine.empty.docs.trash-description']()
: t['com.affine.empty.docs.all-description']()
}
action={
showActionButton ? (
<ActionButton onClick={onCreate} prefix={<AllDocsIcon />}>
{t['com.affine.empty.docs.action.new-doc']()}
</ActionButton>
) : null
}
{...props}
/>
);
};
@@ -0,0 +1,4 @@
export * from './collection-detail';
export * from './collections';
export * from './docs';
export * from './tags';
@@ -0,0 +1,45 @@
import { ThemedImg, withUnit } from '@affine/component';
import clsx from 'clsx';
import * as styles from './style.css';
import type { EmptyLayoutProps } from './types';
export const EmptyLayout = ({
className,
illustrationLight,
illustrationDark,
illustrationWidth = 300,
title,
description,
action,
absoluteCenter,
...attrs
}: EmptyLayoutProps) => {
return (
<div
className={clsx(
styles.root,
absoluteCenter ? styles.absoluteCenter : null,
className
)}
{...attrs}
>
<ThemedImg
style={{ width: withUnit(illustrationWidth, 'px') }}
draggable={false}
className={styles.illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
{title || description ? (
<div>
<p className={styles.title}>{title}</p>
<p className={styles.description}>{description}</p>
</div>
) : null}
{action}
</div>
);
};
@@ -0,0 +1,64 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
// SVG illustration has a space from top, add some padding bottom to compensate
paddingBottom: 35,
});
export const illustration = style({
maxWidth: '100%',
width: 300,
});
export const title = style({
textAlign: 'center',
fontSize: 15,
lineHeight: '24px',
fontWeight: 500,
color: cssVarV2('text/primary'),
marginBottom: 4,
});
export const description = style({
textAlign: 'center',
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVarV2('text/secondary'),
marginBottom: 0,
maxWidth: 300,
});
export const absoluteCenter = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
});
export const actionGroup = style({
display: 'flex',
gap: 16,
});
export const actionButton = style({});
export const mobileActionButton = style({
padding: '8px 18px',
height: 'auto',
fontWeight: 600,
});
export const actionContent = style({
padding: '0 4px',
});
export const mobileActionContent = style({
padding: '0 4px',
});
@@ -0,0 +1,20 @@
import { useI18n } from '@affine/i18n';
import tagsDark from './assets/tag-list.dark.png';
import tagsLight from './assets/tag-list.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export const EmptyTags = (props: UniversalEmptyProps) => {
const t = useI18n();
return (
<EmptyLayout
illustrationLight={tagsLight}
illustrationDark={tagsDark}
title={t['com.affine.empty.tags.title']()}
description={t['com.affine.empty.tags.description']()}
{...props}
/>
);
};
@@ -0,0 +1,18 @@
import type { HTMLAttributes, ReactNode } from 'react';
export interface EmptyLayoutProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
illustrationLight: string;
illustrationDark?: string;
illustrationWidth?: number | string;
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
/**
* Absolute center the content, useful for full screen empty states (e.g. mobile page)
*/
absoluteCenter?: boolean;
}
export type UniversalEmptyProps = Partial<EmptyLayoutProps>;
@@ -1,33 +1,27 @@
import { Button } from '@affine/component';
import { useI18n } from '@affine/i18n';
import type { ReactElement } from 'react';
import * as styles from './collection-list-header.css';
export const CollectionListHeader = ({
node,
onCreate,
}: {
node: ReactElement | null;
onCreate: () => void;
}) => {
const t = useI18n();
return (
<>
<div className={styles.collectionListHeader}>
<div className={styles.collectionListHeaderTitle}>
{t['com.affine.collections.header']()}
</div>
<Button
className={styles.newCollectionButton}
onClick={onCreate}
data-testid="all-collection-new-button"
>
{t['com.affine.collections.empty.new-collection-button']()}
</Button>
<div className={styles.collectionListHeader}>
<div className={styles.collectionListHeaderTitle}>
{t['com.affine.collections.header']()}
</div>
{node}
</>
<Button
className={styles.newCollectionButton}
onClick={onCreate}
data-testid="all-collection-new-button"
>
{t['com.affine.collections.empty.new-collection-button']()}
</Button>
</div>
);
};
@@ -2,7 +2,6 @@ import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-co
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService, WorkspaceService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
@@ -42,12 +41,10 @@ export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
node,
handleCreateCollection,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
node: ReactElement | null;
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
@@ -107,9 +104,7 @@ export const VirtualizedCollectionList = ({
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewCollection}
onSelectionActiveChange={setShowFloatingToolbar}
heading={
<CollectionListHeader node={node} onCreate={handleCreateCollection} />
}
heading={<CollectionListHeader onCreate={handleCreateCollection} />}
selectedIds={filteredSelectedCollectionIds}
onSelectedIdsChange={setSelectedCollectionIds}
items={collectionMetas}
@@ -110,7 +110,7 @@ export const CollectionPageListHeader = ({
jumpToCollections(workspaceId);
}, [jumpToCollections, workspaceId]);
const { node, open } = useEditCollection();
const { open } = useEditCollection();
const handleEdit = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
@@ -162,32 +162,29 @@ export const CollectionPageListHeader = ({
}, [createPage, onConfirmAddDocument]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
<PageListNewPageButton
size="small"
testId="new-page-button-trigger"
onCreateDoc={onCreateDoc}
onCreateEdgeless={onCreateEdgeless}
onCreatePage={onCreatePage}
>
<div className={styles.buttonText}>{t['New Page']()}</div>
</PageListNewPageButton>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
</div>
</>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
<PageListNewPageButton
size="small"
testId="new-page-button-trigger"
onCreateDoc={onCreateDoc}
onCreateEdgeless={onCreateEdgeless}
onCreatePage={onCreatePage}
>
<div className={styles.buttonText}>{t['New Page']()}</div>
</PageListNewPageButton>
</div>
</div>
);
};
@@ -205,7 +202,7 @@ export const TagPageListHeader = ({
const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService);
const [openMenu, setOpenMenu] = useState(false);
const { open, node } = useEditCollectionName({
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
@@ -235,47 +232,44 @@ export const TagPageListHeader = ({
}, [open, saveToCollection]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
onClick={handleJumpToTags}
>
{t['Tags']()} /
</div>
<Menu
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 18,
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<SwitchTag onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColor,
}}
/>
<div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
onClick={handleJumpToTags}
>
{t['Tags']()} /
</div>
<Button onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()}
</Button>
<Menu
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 18,
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<SwitchTag onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColor,
}}
/>
<div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
</div>
</>
<Button onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()}
</Button>
</div>
);
};
@@ -317,13 +317,11 @@ export const CollectionOperationCell = ({
favAdapter.isFavorite$(collection.id, 'collection')
);
const { open: openEditCollectionModal, node: editModal } =
useEditCollection();
const { open: openEditCollectionModal } = useEditCollection();
const { open: openEditCollectionNameModal, node: editNameModal } =
useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const { open: openEditCollectionNameModal } = useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const handlePropagation = useCallback((event: MouseEvent) => {
event.preventDefault();
@@ -402,8 +400,6 @@ export const CollectionOperationCell = ({
return (
<>
{editModal}
{editNameModal}
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"
@@ -51,13 +51,11 @@ export const CollectionOperations = ({
});
const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench;
const { open: openEditCollectionModal, node: editModal } =
useEditCollection();
const { open: openEditCollectionModal } = useEditCollection();
const t = useI18n();
const { open: openEditCollectionNameModal, node: editNameModal } =
useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const { open: openEditCollectionNameModal } = useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
@@ -193,33 +191,29 @@ export const CollectionOperations = ({
]
);
return (
<>
{editModal}
{editNameModal}
<Menu
items={
<div style={{ minWidth: 150 }}>
{actions.map(action => {
if (action.element) {
return action.element;
}
return (
<MenuItem
data-testid="collection-option"
key={action.name}
type={action.type}
prefixIcon={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</div>
}
>
{children}
</Menu>
</>
<Menu
items={
<div style={{ minWidth: 150 }}>
{actions.map(action => {
if (action.element) {
return action.element;
}
return (
<MenuItem
data-testid="collection-option"
key={action.name}
type={action.type}
prefixIcon={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</div>
}
>
{children}
</Menu>
);
};
@@ -17,7 +17,7 @@ export const SaveAsCollectionButton = ({
onConfirm,
}: SaveAsCollectionButtonProps) => {
const t = useI18n();
const { open, node } = useEditCollectionName({
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
@@ -31,16 +31,13 @@ export const SaveAsCollectionButton = ({
});
}, [open, onConfirm]);
return (
<>
<Button
onClick={handleClick}
data-testid="save-as-collection"
prefix={<SaveIcon />}
className={styles.button}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
{node}
</>
<Button
onClick={handleClick}
data-testid="save-as-collection"
prefix={<SaveIcon />}
className={styles.button}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
);
};
@@ -1,5 +1,6 @@
import type { Collection } from '@affine/env/filter';
import { useCallback, useState } from 'react';
import { useMount } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { CreateCollectionModal } from './create-collection';
import type { EditCollectionMode } from './edit-collection/edit-collection';
@@ -16,9 +17,11 @@ export const useEditCollection = () => {
setData(undefined);
}
}, []);
const { mount } = useMount('useEditCollection');
return {
node: (
useEffect(() => {
if (!data) return;
return mount(
<EditCollectionModal
init={data?.collection}
open={!!data}
@@ -26,7 +29,10 @@ export const useEditCollection = () => {
onOpenChange={close}
onConfirm={data?.onConfirm ?? (() => {})}
/>
),
);
}, [close, data, mount]);
return {
open: (
collection: Collection,
mode?: EditCollectionMode
@@ -59,9 +65,11 @@ export const useEditCollectionName = ({
setData(undefined);
}
}, []);
const { mount } = useMount('useEditCollectionName');
return {
node: (
useEffect(() => {
if (!data) return;
return mount(
<CreateCollectionModal
showTips={showTips}
title={title}
@@ -70,7 +78,10 @@ export const useEditCollectionName = ({
onOpenChange={close}
onConfirm={data?.onConfirm ?? (() => {})}
/>
),
);
}, [close, data, mount, showTips, title]);
return {
open: (name: string): Promise<string> =>
new Promise<string>(res => {
setData({