refactor(core): new workspace selector and create dialog (#10323)

This commit is contained in:
Cats Juice
2025-02-21 17:16:38 +08:00
committed by GitHub
parent 22e4bd8c20
commit 09cc5c392b
28 changed files with 760 additions and 563 deletions

View File

@@ -25,6 +25,8 @@ export interface MenuItemProps
suffix?: ReactNode; suffix?: ReactNode;
prefixIcon?: ReactNode; prefixIcon?: ReactNode;
suffixIcon?: ReactNode; suffixIcon?: ReactNode;
prefixIconClassName?: string;
suffixIconClassName?: string;
checked?: boolean; checked?: boolean;
selected?: boolean; selected?: boolean;
block?: boolean; block?: boolean;

View File

@@ -11,8 +11,10 @@ export const useMenuItem = <T extends MenuItemProps>({
className: propsClassName, className: propsClassName,
prefix, prefix,
prefixIcon, prefixIcon,
prefixIconClassName,
suffix, suffix,
suffixIcon, suffixIcon,
suffixIconClassName,
checked, checked,
selected, selected,
block, block,
@@ -38,13 +40,17 @@ export const useMenuItem = <T extends MenuItemProps>({
{prefix} {prefix}
{prefixIcon ? ( {prefixIcon ? (
<div className={styles.menuItemIcon}>{prefixIcon}</div> <div className={clsx(styles.menuItemIcon, prefixIconClassName)}>
{prefixIcon}
</div>
) : null} ) : null}
<span className={styles.menuSpan}>{propsChildren}</span> <span className={styles.menuSpan}>{propsChildren}</span>
{suffixIcon ? ( {suffixIcon ? (
<div className={styles.menuItemIcon}>{suffixIcon}</div> <div className={clsx(styles.menuItemIcon, suffixIconClassName)}>
{suffixIcon}
</div>
) : null} ) : null}
{suffix} {suffix}

View File

@@ -12,6 +12,7 @@ export type ScrollableContainerProps = {
viewPortClassName?: string; viewPortClassName?: string;
styles?: React.CSSProperties; styles?: React.CSSProperties;
scrollBarClassName?: string; scrollBarClassName?: string;
scrollThumbClassName?: string;
}; };
export const ScrollableContainer = ({ export const ScrollableContainer = ({
@@ -22,6 +23,7 @@ export const ScrollableContainer = ({
styles: _styles, styles: _styles,
viewPortClassName, viewPortClassName,
scrollBarClassName, scrollBarClassName,
scrollThumbClassName,
}: PropsWithChildren<ScrollableContainerProps>) => { }: PropsWithChildren<ScrollableContainerProps>) => {
const [setContainer, hasScrollTop] = useHasScrollTop(); const [setContainer, hasScrollTop] = useHasScrollTop();
return ( return (
@@ -45,7 +47,9 @@ export const ScrollableContainer = ({
[styles.TableScrollbar]: inTableView, [styles.TableScrollbar]: inTableView,
})} })}
> >
<ScrollArea.Thumb className={styles.scrollbarThumb} /> <ScrollArea.Thumb
className={clsx(styles.scrollbarThumb, scrollThumbClassName)}
/>
</ScrollArea.Scrollbar> </ScrollArea.Scrollbar>
</ScrollArea.Root> </ScrollArea.Root>
); );

View File

@@ -97,6 +97,8 @@ export const WorkspaceSelector = ({
...menuContentOptions, ...menuContentOptions,
style: { style: {
width: '300px', width: '300px',
maxHeight: 'min(800px, calc(100vh - 200px))',
padding: 0,
...menuContentOptions?.style, ...menuContentOptions?.style,
}, },
}} }}

View File

@@ -1,23 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const ItemContainer = style({
display: 'flex', export const addServerDividerWrapper = style({
alignItems: 'center', padding: '0px 12px',
justifyContent: 'flex-start',
padding: '8px 14px',
gap: '14px',
cursor: 'pointer',
borderRadius: '8px',
transition: 'background-color 0.2s',
fontSize: '24px',
color: cssVar('iconSecondary'),
});
export const ItemText = style({
fontSize: cssVar('fontSm'),
lineHeight: '22px',
color: cssVar('textSecondaryColor'),
fontWeight: 400,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}); });

View File

@@ -1,34 +1,50 @@
import { MenuItem } from '@affine/component/ui/menu'; import { Divider, MenuItem } from '@affine/component';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc'; import { PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './index.css'; import {
ItemContainer,
ItemText,
prefixIcon,
} from '../add-workspace/index.css';
import { addServerDividerWrapper } from './index.css';
export const AddServer = ({ onAddServer }: { onAddServer?: () => void }) => { export const AddServer = () => {
const t = useI18n(); const t = useI18n();
const globalDialogService = useService(GlobalDialogService);
const featureFlagService = useService(FeatureFlagService); const featureFlagService = useService(FeatureFlagService);
const enableMultipleServer = useLiveData( const enableMultipleServer = useLiveData(
featureFlagService.flags.enable_multiple_cloud_servers.$ featureFlagService.flags.enable_multiple_cloud_servers.$
); );
const onAddServer = useCallback(() => {
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
}, [globalDialogService]);
if (!enableMultipleServer) { if (!enableMultipleServer) {
return null; return null;
} }
return ( return (
<div> <>
<div className={addServerDividerWrapper}>
<Divider size="thinner" />
</div>
<MenuItem <MenuItem
block={true} block={true}
prefixIcon={<PlusIcon />} prefixIcon={<PlusIcon />}
prefixIconClassName={prefixIcon}
onClick={onAddServer} onClick={onAddServer}
data-testid="new-server" data-testid="new-server"
className={styles.ItemContainer} className={ItemContainer}
> >
<div className={styles.ItemText}> <div className={ItemText}>
{t['com.affine.workspaceList.addServer']()} {t['com.affine.workspaceList.addServer']()}
</div> </div>
</MenuItem> </MenuItem>
</div> </>
); );
}; };

View File

@@ -1,21 +1,28 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const ItemContainer = style({ export const ItemContainer = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-start', justifyContent: 'flex-start',
padding: '8px 14px', padding: '6px 16px 6px 11px',
gap: '14px', gap: '12px',
cursor: 'pointer', cursor: 'pointer',
borderRadius: '8px', borderRadius: '8px',
transition: 'background-color 0.2s', transition: 'background-color 0.2s',
fontSize: '24px', fontSize: '24px',
color: cssVar('iconSecondary'), });
export const prefixIcon = style({
width: 24,
height: 24,
fontSize: 24,
color: cssVarV2.icon.secondary,
}); });
export const ItemText = style({ export const ItemText = style({
fontSize: cssVar('fontSm'), fontSize: cssVar('fontSm'),
lineHeight: '22px', lineHeight: '22px',
color: cssVar('textSecondaryColor'), color: cssVarV2.text.secondary,
fontWeight: 400, fontWeight: 400,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',

View File

@@ -20,11 +20,12 @@ export const AddWorkspace = ({
); );
return ( return (
<div> <>
{BUILD_CONFIG.isElectron && ( {BUILD_CONFIG.isElectron && (
<MenuItem <MenuItem
block={true} block={true}
prefixIcon={<ImportIcon />} prefixIcon={<ImportIcon />}
prefixIconClassName={styles.prefixIcon}
onClick={onAddWorkspace} onClick={onAddWorkspace}
data-testid="add-workspace" data-testid="add-workspace"
className={styles.ItemContainer} className={styles.ItemContainer}
@@ -37,6 +38,7 @@ export const AddWorkspace = ({
<MenuItem <MenuItem
block={true} block={true}
prefixIcon={<PlusIcon />} prefixIcon={<PlusIcon />}
prefixIconClassName={styles.prefixIcon}
onClick={onNewWorkspace} onClick={onNewWorkspace}
data-testid="new-workspace" data-testid="new-workspace"
className={styles.ItemContainer} className={styles.ItemContainer}
@@ -47,6 +49,6 @@ export const AddWorkspace = ({
: t['com.affine.workspaceList.addWorkspace.create-cloud']()} : t['com.affine.workspaceList.addWorkspace.create-cloud']()}
</div> </div>
</MenuItem> </MenuItem>
</div> </>
); );
}; };

View File

@@ -1,10 +1,26 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const workspaceListWrapper = style({
export const workspaceScrollArea = style({
display: 'flex', display: 'flex',
width: '100%',
flexDirection: 'column', flexDirection: 'column',
}); });
export const workspaceScrollAreaViewport = style({
padding: '10px 8px 0px 8px',
});
export const workspaceFooter = style({
padding: '0px 8px 10px 8px',
});
export const scrollbar = style({
width: 9,
padding: '0px 2px',
':hover': {
padding: 0,
},
});
export const scrollbarThumb = style({
width: 5,
});
export const signInWrapper = style({ export const signInWrapper = style({
display: 'flex', display: 'flex',
width: '100%', width: '100%',

View File

@@ -1,3 +1,4 @@
import { ScrollableContainer } from '@affine/component';
import { MenuItem } from '@affine/component/ui/menu'; import { MenuItem } from '@affine/component/ui/menu';
import { AuthService } from '@affine/core/modules/cloud'; import { AuthService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { GlobalDialogService } from '@affine/core/modules/dialogs';
@@ -9,7 +10,6 @@ import { Logo1Icon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { AddServer } from './add-server';
import { AddWorkspace } from './add-workspace'; import { AddWorkspace } from './add-workspace';
import * as styles from './index.css'; import * as styles from './index.css';
import { AFFiNEWorkspaceList } from './workspace-list'; import { AFFiNEWorkspaceList } from './workspace-list';
@@ -58,7 +58,7 @@ interface UserWithWorkspaceListProps {
showEnableCloudButton?: boolean; showEnableCloudButton?: boolean;
} }
const UserWithWorkspaceListInner = ({ export const UserWithWorkspaceList = ({
onEventEnd, onEventEnd,
onClickWorkspace, onClickWorkspace,
onCreatedWorkspace, onCreatedWorkspace,
@@ -109,26 +109,26 @@ const UserWithWorkspaceListInner = ({
onEventEnd?.(); onEventEnd?.();
}, [globalDialogService, onCreatedWorkspace, onEventEnd]); }, [globalDialogService, onCreatedWorkspace, onEventEnd]);
const onAddServer = useCallback(() => {
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
}, [globalDialogService]);
return ( return (
<div className={styles.workspaceListWrapper}> <>
<ScrollableContainer
className={styles.workspaceScrollArea}
viewPortClassName={styles.workspaceScrollAreaViewport}
scrollBarClassName={styles.scrollbar}
scrollThumbClassName={styles.scrollbarThumb}
>
<AFFiNEWorkspaceList <AFFiNEWorkspaceList
onEventEnd={onEventEnd} onEventEnd={onEventEnd}
onClickWorkspace={onClickWorkspace} onClickWorkspace={onClickWorkspace}
showEnableCloudButton={showEnableCloudButton} showEnableCloudButton={showEnableCloudButton}
/> />
</ScrollableContainer>
<div className={styles.workspaceFooter}>
<AddWorkspace <AddWorkspace
onAddWorkspace={onAddWorkspace} onAddWorkspace={onAddWorkspace}
onNewWorkspace={onNewWorkspace} onNewWorkspace={onNewWorkspace}
/> />
<AddServer onAddServer={onAddServer} />
</div> </div>
</>
); );
}; };
export const UserWithWorkspaceList = (props: UserWithWorkspaceListProps) => {
return <UserWithWorkspaceListInner {...props} />;
};

View File

@@ -1,81 +1,73 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const workspaceListsWrapper = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
maxHeight: 'calc(100vh - 300px)',
});
export const workspaceListWrapper = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: 2,
});
export const workspaceServer = style({ export const workspaceServer = style({
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 4, alignItems: 'center',
paddingLeft: '12px', padding: '0px 8px',
marginBottom: '4px', gap: 8,
marginBottom: 6,
});
export const workspaceServerIcon = style({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
borderRadius: 4,
color: cssVarV2.icon.primary,
fontSize: 18,
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}); });
export const workspaceServerContent = style({ export const workspaceServerContent = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
color: cssVarV2('text/secondary'), });
gap: 4, const ellipsis = style({
width: '100%', textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
}); });
export const workspaceServerName = style({ export const workspaceServerAccount = style([
display: 'flex', ellipsis,
alignItems: 'center', {
gap: 4,
fontWeight: 500,
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
lineHeight: '20px', lineHeight: '20px',
textOverflow: 'ellipsis', color: cssVarV2.text.secondary,
whiteSpace: 'nowrap', marginTop: -1.5,
}); },
export const account = style({ ]);
export const workspaceServerName = style([
ellipsis,
{
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
overflow: 'hidden', lineHeight: '20px',
width: '100%', fontWeight: 500,
textOverflow: 'ellipsis', color: cssVarV2.text.primary,
whiteSpace: 'nowrap', selectors: {
}); [`&:has(~ ${workspaceServerAccount})`]: {
export const workspaceTypeIcon = style({ marginBottom: -1.5,
color: cssVarV2('icon/primary'), },
fontSize: '16px', },
}); },
export const scrollbar = style({ ]);
width: '4px',
}); export const workspaceServerSpacer = style({
export const workspaceCard = style({ width: 0,
height: '44px', flexGrow: 1,
padding: '0 12px',
}); });
export const ItemContainer = style({ export const workspaceCard = style({
display: 'flex', height: 36,
alignItems: 'center', padding: '7px 12px',
justifyContent: 'flex-start',
padding: '8px 14px',
gap: '14px',
cursor: 'pointer',
borderRadius: '8px',
transition: 'background-color 0.2s',
fontSize: '24px',
color: cssVarV2('icon/secondary'),
}); });
export const ItemText = style({ export const workspaceCardInfoContainer = style({
fontSize: cssVar('fontSm'), gap: 12,
lineHeight: '22px', });
color: cssVarV2('text/secondary'),
fontWeight: 400, export const serverDivider = style({
whiteSpace: 'nowrap', marginTop: 8,
overflow: 'hidden', marginBottom: 12,
textOverflow: 'ellipsis',
}); });

View File

@@ -1,14 +1,9 @@
import { import { IconButton, Menu, MenuItem } from '@affine/component';
IconButton,
Menu,
MenuItem,
ScrollableContainer,
} from '@affine/component';
import { Divider } from '@affine/component/ui/divider'; import { Divider } from '@affine/component/ui/divider';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud'; import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type { Server } from '@affine/core/modules/cloud'; import type { AuthAccountInfo, Server } from '@affine/core/modules/cloud';
import { AuthService, ServersService } from '@affine/core/modules/cloud'; import { AuthService, ServersService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { GlobalContextService } from '@affine/core/modules/global-context'; import { GlobalContextService } from '@affine/core/modules/global-context';
@@ -17,14 +12,15 @@ import {
WorkspaceService, WorkspaceService,
WorkspacesService, WorkspacesService,
} from '@affine/core/modules/workspace'; } from '@affine/core/modules/workspace';
import { ServerDeploymentType } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { import {
AccountIcon,
CloudWorkspaceIcon, CloudWorkspaceIcon,
DeleteIcon,
LocalWorkspaceIcon, LocalWorkspaceIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
PlusIcon, SelfhostIcon,
TeamWorkspaceIcon, SignOutIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import { import {
FrameworkScope, FrameworkScope,
@@ -35,6 +31,7 @@ import {
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { WorkspaceCard } from '../../workspace-card'; import { WorkspaceCard } from '../../workspace-card';
import { AddServer } from '../add-server';
import * as styles from './index.css'; import * as styles from './index.css';
interface WorkspaceModalProps { interface WorkspaceModalProps {
@@ -46,6 +43,91 @@ interface WorkspaceModalProps {
onAddWorkspace: () => void; onAddWorkspace: () => void;
} }
const WorkspaceServerInfo = ({
server,
name,
account,
accountStatus,
onDeleteServer,
onSignOut,
onSignIn,
}: {
server: string;
name: string;
account?: AuthAccountInfo | null;
accountStatus?: 'authenticated' | 'unauthenticated';
onDeleteServer?: () => void;
onSignOut?: () => void;
onSignIn?: () => void;
}) => {
const t = useI18n();
const isCloud = server !== 'local';
const isAffineCloud = server === 'affine-cloud';
const Icon = isAffineCloud
? CloudWorkspaceIcon
: isCloud
? SelfhostIcon
: LocalWorkspaceIcon;
const menuItems = useMemo(
() =>
[
server !== 'affine-cloud' && server !== 'local' && (
<MenuItem
prefixIcon={<DeleteIcon />}
type="danger"
key="delete-server"
onClick={onDeleteServer}
>
{t['com.affine.server.delete']()}
</MenuItem>
),
accountStatus === 'authenticated' && (
<MenuItem
prefixIcon={<SignOutIcon />}
key="sign-out"
onClick={onSignOut}
type="danger"
>
{t['Sign out']()}
</MenuItem>
),
accountStatus === 'unauthenticated' && (
<MenuItem
prefixIcon={<AccountIcon />}
key="sign-in"
onClick={onSignIn}
>
{t['Sign in']()}
</MenuItem>
),
].filter(Boolean),
[accountStatus, onDeleteServer, onSignIn, onSignOut, server, t]
);
return (
<div className={styles.workspaceServer}>
<div className={styles.workspaceServerIcon}>
<Icon />
</div>
<div className={styles.workspaceServerContent}>
<div className={styles.workspaceServerName}>{name}</div>
{isCloud ? (
<div className={styles.workspaceServerAccount}>
{account ? account.email : 'Not signed in'}
</div>
) : null}
</div>
<div className={styles.workspaceServerSpacer} />
{menuItems.length ? (
<Menu items={menuItems}>
<IconButton icon={<MoreHorizontalIcon />} />
</Menu>
) : null}
</div>
);
};
const CloudWorkSpaceList = ({ const CloudWorkSpaceList = ({
server, server,
workspaces, workspaces,
@@ -57,7 +139,6 @@ const CloudWorkSpaceList = ({
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void; onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (meta: WorkspaceMetadata) => void; onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
}) => { }) => {
const t = useI18n();
const globalContextService = useService(GlobalContextService); const globalContextService = useService(GlobalContextService);
const globalDialogService = useService(GlobalDialogService); const globalDialogService = useService(GlobalDialogService);
const serverName = useLiveData(server.config$.selector(c => c.serverName)); const serverName = useLiveData(server.config$.selector(c => c.serverName));
@@ -71,8 +152,6 @@ const CloudWorkSpaceList = ({
globalContextService.globalContext.workspaceFlavour.$ globalContextService.globalContext.workspaceFlavour.$
); );
const serverType = server.config$.value.type;
const handleDeleteServer = useCallback(() => { const handleDeleteServer = useCallback(() => {
serversService.removeServer(server.id); serversService.removeServer(server.id);
@@ -100,78 +179,23 @@ const CloudWorkSpaceList = ({
}); });
}, [globalDialogService, server.baseUrl]); }, [globalDialogService, server.baseUrl]);
const onNewWorkspace = useCallback(() => {
globalDialogService.open(
'create-workspace',
{
serverId: server.id,
forcedCloud: true,
},
payload => {
if (payload) {
navigateHelper.openPage(payload.metadata.id, 'all');
}
}
);
}, [globalDialogService, navigateHelper, server.id]);
return ( return (
<div className={styles.workspaceListWrapper}> <>
<div className={styles.workspaceServer}> <WorkspaceServerInfo
<div className={styles.workspaceServerContent}> server={server.id}
<div className={styles.workspaceServerName}> name={serverName}
{serverType === ServerDeploymentType.Affine ? ( account={account}
<CloudWorkspaceIcon className={styles.workspaceTypeIcon} /> accountStatus={accountStatus}
) : ( onDeleteServer={handleDeleteServer}
<TeamWorkspaceIcon className={styles.workspaceTypeIcon} /> onSignOut={handleSignOut}
)} onSignIn={handleSignIn}
<div className={styles.account}>{serverName}</div> />
</div>
<div className={styles.account}>
{account ? account.email : 'Not signed in'}
</div>
</div>
<Menu
items={[
server.id !== 'affine-cloud' && (
<MenuItem key="delete-server" onClick={handleDeleteServer}>
{t['com.affine.server.delete']()}
</MenuItem>
),
accountStatus === 'authenticated' && (
<MenuItem key="sign-out" onClick={handleSignOut}>
{t['Sign out']()}
</MenuItem>
),
accountStatus === 'unauthenticated' && (
<MenuItem key="sign-in" onClick={handleSignIn}>
{t['Sign in']()}
</MenuItem>
),
]}
>
<div>
<IconButton icon={<MoreHorizontalIcon />} />
</div>
</Menu>
</div>
<WorkspaceList <WorkspaceList
items={workspaces} items={workspaces}
onClick={onClickWorkspace} onClick={onClickWorkspace}
onEnableCloudClick={onClickEnableCloud} onEnableCloudClick={onClickEnableCloud}
/> />
<MenuItem </>
block={true}
prefixIcon={<PlusIcon />}
onClick={onNewWorkspace}
className={styles.ItemContainer}
>
<div className={styles.ItemText}>
{t['com.affine.workspaceList.addWorkspace.create']()}
</div>
</MenuItem>
</div>
); );
}; };
@@ -186,25 +210,18 @@ const LocalWorkspaces = ({
return null; return null;
} }
return ( return (
<div className={styles.workspaceListWrapper}> <>
<div className={styles.workspaceServer}> <WorkspaceServerInfo
<div className={styles.workspaceServerName}> server="local"
<LocalWorkspaceIcon name={t['com.affine.workspaceList.workspaceListType.local']()}
width={14}
height={14}
className={styles.workspaceTypeIcon}
/> />
{t['com.affine.workspaceList.workspaceListType.local']()}
</div>
</div>
<WorkspaceList <WorkspaceList
items={workspaces} items={workspaces}
onClick={onClickWorkspace} onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting} onSettingClick={onClickWorkspaceSetting}
onEnableCloudClick={onClickEnableCloud} onEnableCloudClick={onClickEnableCloud}
/> />
<Divider size="thinner" /> </>
</div>
); );
}; };
@@ -224,6 +241,14 @@ export const AFFiNEWorkspaceList = ({
const serversService = useService(ServersService); const serversService = useService(ServersService);
const servers = useLiveData(serversService.servers$); const servers = useLiveData(serversService.servers$);
const affineCloudServer = useMemo(
() => servers.find(s => s.id === 'affine-cloud') as Server,
[servers]
);
const selfhostServers = useMemo(
() => servers.filter(s => s.id !== 'affine-cloud'),
[servers]
);
const cloudWorkspaces = useMemo( const cloudWorkspaces = useMemo(
() => () =>
@@ -262,12 +287,39 @@ export const AFFiNEWorkspaceList = ({
); );
return ( return (
<ScrollableContainer <>
className={styles.workspaceListsWrapper} {/* 1. affine-cloud */}
scrollBarClassName={styles.scrollbar} <FrameworkScope
key={affineCloudServer.id}
scope={affineCloudServer.scope}
> >
<div> <CloudWorkSpaceList
{servers.map(server => ( server={affineCloudServer}
workspaces={cloudWorkspaces.filter(
({ flavour }) => flavour === affineCloudServer.id
)}
onClickWorkspace={handleClickWorkspace}
/>
</FrameworkScope>
{localWorkspaces.length > 0 ||
(selfhostServers.length > 0 && (
<Divider size="thinner" className={styles.serverDivider} />
))}
{/* 2. local */}
<LocalWorkspaces
workspaces={localWorkspaces}
onClickWorkspace={handleClickWorkspace}
onClickEnableCloud={
showEnableCloudButton ? onClickEnableCloud : undefined
}
/>
{selfhostServers.length > 0 && (
<Divider size="thinner" className={styles.serverDivider} />
)}
{/* 3. selfhost */}
{selfhostServers.map((server, index) => (
<FrameworkScope key={server.id} scope={server.scope}> <FrameworkScope key={server.id} scope={server.scope}>
<CloudWorkSpaceList <CloudWorkSpaceList
server={server} server={server}
@@ -276,18 +328,14 @@ export const AFFiNEWorkspaceList = ({
)} )}
onClickWorkspace={handleClickWorkspace} onClickWorkspace={handleClickWorkspace}
/> />
<Divider size="thinner" /> {index !== selfhostServers.length - 1 && (
<Divider size="thinner" className={styles.serverDivider} />
)}
</FrameworkScope> </FrameworkScope>
))} ))}
</div> <AddServer />
<LocalWorkspaces <Divider size="thinner" />
workspaces={localWorkspaces} </>
onClickWorkspace={handleClickWorkspace}
onClickEnableCloud={
showEnableCloudButton ? onClickEnableCloud : undefined
}
/>
</ScrollableContainer>
); );
}; };
@@ -317,9 +365,10 @@ const SortableWorkspaceItem = ({
return ( return (
<WorkspaceCard <WorkspaceCard
className={styles.workspaceCard} className={styles.workspaceCard}
infoClassName={styles.workspaceCardInfoContainer}
workspaceMetadata={workspaceMetadata} workspaceMetadata={workspaceMetadata}
onClick={handleClick} onClick={handleClick}
avatarSize={28} avatarSize={22}
active={currentWorkspace?.id === workspaceMetadata.id} active={currentWorkspace?.id === workspaceMetadata.id}
onClickOpenSettings={onSettingClick} onClickOpenSettings={onSettingClick}
onClickEnableCloud={onEnableCloudClick} onClickEnableCloud={onEnableCloudClick}

View File

@@ -249,6 +249,7 @@ export const WorkspaceCard = forwardRef<
hideCollaborationIcon?: boolean; hideCollaborationIcon?: boolean;
hideTeamWorkspaceIcon?: boolean; hideTeamWorkspaceIcon?: boolean;
active?: boolean; active?: boolean;
infoClassName?: string;
onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void; onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void; onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void;
} }
@@ -262,6 +263,7 @@ export const WorkspaceCard = forwardRef<
onClickOpenSettings, onClickOpenSettings,
onClickEnableCloud, onClickEnableCloud,
className, className,
infoClassName,
disable, disable,
hideCollaborationIcon, hideCollaborationIcon,
hideTeamWorkspaceIcon, hideTeamWorkspaceIcon,
@@ -296,7 +298,7 @@ export const WorkspaceCard = forwardRef<
ref={ref} ref={ref}
{...props} {...props}
> >
<div className={styles.infoContainer}> <div className={clsx(styles.infoContainer, infoClassName)}>
{information ? ( {information ? (
<WorkspaceAvatar <WorkspaceAvatar
meta={workspaceMetadata} meta={workspaceMetadata}
@@ -332,6 +334,16 @@ export const WorkspaceCard = forwardRef<
Enable Cloud Enable Cloud
</Button> </Button>
) : null} ) : null}
{onClickOpenSettings && (
<div className={styles.settingButton} onClick={onOpenSettings}>
<SettingsIcon width={16} height={16} />
</div>
)}
</div>
</div>
<div className={styles.suffixIcons}>
{hideCollaborationIcon || information?.isOwner ? null : ( {hideCollaborationIcon || information?.isOwner ? null : (
<Tooltip <Tooltip
content={t['com.affine.settings.workspace.state.joined']()} content={t['com.affine.settings.workspace.state.joined']()}
@@ -340,26 +352,17 @@ export const WorkspaceCard = forwardRef<
</Tooltip> </Tooltip>
)} )}
{hideTeamWorkspaceIcon || !information?.isTeam ? null : ( {hideTeamWorkspaceIcon || !information?.isTeam ? null : (
<Tooltip <Tooltip content={t['com.affine.settings.workspace.state.team']()}>
content={t['com.affine.settings.workspace.state.team']()}
>
<TeamWorkspaceIcon className={styles.collaborationIcon} /> <TeamWorkspaceIcon className={styles.collaborationIcon} />
</Tooltip> </Tooltip>
)} )}
{onClickOpenSettings && (
<div className={styles.settingButton} onClick={onOpenSettings}>
<SettingsIcon width={16} height={16} />
</div>
)}
</div>
{showArrowDownIcon && <ArrowDownSmallIcon />}
</div>
{active && ( {active && (
<div className={styles.activeContainer}> <div className={styles.activeContainer}>
<DoneIcon className={styles.activeIcon} /> <DoneIcon className={styles.activeIcon} />
</div> </div>
)} )}
{showArrowDownIcon && <ArrowDownSmallIcon />}
</div>
</div> </div>
); );
} }

View File

@@ -19,6 +19,7 @@ export const container = style({
width: '100%', width: '100%',
maxWidth: 500, maxWidth: 500,
color: cssVarV2('text/primary'), color: cssVarV2('text/primary'),
overflow: 'hidden',
':hover': { ':hover': {
cursor: 'pointer', cursor: 'pointer',
background: cssVar('hoverColor'), background: cssVar('hoverColor'),
@@ -34,6 +35,7 @@ export const infoContainer = style({
}); });
export const activeContainer = style({ export const activeContainer = style({
flexShrink: 0, flexShrink: 0,
lineHeight: 0,
}); });
export const disable = style({ export const disable = style({
@@ -187,6 +189,9 @@ export const showOnCardHover = style({
[`.${container}:hover &`]: { [`.${container}:hover &`]: {
position: 'relative', position: 'relative',
}, },
'&:empty': {
display: 'none',
},
}, },
}); });
@@ -194,3 +199,14 @@ export const activeIcon = style({
fontSize: 14, fontSize: 14,
color: cssVarV2('icon/activated'), color: cssVarV2('icon/activated'),
}); });
export const suffixIcons = style({
display: 'flex',
gap: 8,
alignItems: 'center',
selectors: {
'&:empty': {
display: 'none',
},
},
});

View File

@@ -1,77 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const header = style({
position: 'relative',
marginTop: '44px',
});
export const subTitle = style({
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
fontWeight: 600,
});
export const avatarWrapper = style({
display: 'flex',
margin: '10px 0',
});
export const workspaceNameWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px 0',
});
export const affineCloudWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '6px',
paddingTop: '10px',
});
export const card = style({
padding: '12px',
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor: cssVar('backgroundSecondaryColor'),
minHeight: '114px',
position: 'relative',
});
export const cardText = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
gap: '12px',
});
export const cardTitle = style({
fontSize: cssVar('fontBase'),
color: cssVar('textPrimaryColor'),
display: 'flex',
justifyContent: 'space-between',
});
export const cardDescription = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
maxWidth: '288px',
});
export const cloudTips = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
});
export const cloudSvgContainer = style({
width: '146px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
position: 'absolute',
bottom: '0',
right: '0',
pointerEvents: 'none',
});

View File

@@ -0,0 +1,44 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const content = style({
// to avoid content clipped
width: `calc(100% + 20px)`,
padding: '10px 10px 20px 10px',
marginLeft: '-10px',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '12px 0px',
});
export const label = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
const baseFormInput = style({
fontSize: 15,
fontWeight: 500,
lineHeight: '24px',
color: cssVarV2.text.primary,
border: `1px solid ${cssVarV2.layer.insideBorder.blackBorder}`,
});
export const input = style([
baseFormInput,
{
borderRadius: 4,
padding: '8px 10px',
},
]);
export const select = style([
baseFormInput,
{
borderRadius: 8,
padding: '10px',
},
]);

View File

@@ -1,194 +1,157 @@
import { Avatar, ConfirmModal, Input, notify, Switch } from '@affine/component'; import { Button, ConfirmModal, notify, RowInput } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, ServersService } from '@affine/core/modules/cloud'; import {
AuthService,
type Server,
ServersService,
} from '@affine/core/modules/cloud';
import { import {
type DialogComponentProps, type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA, type GLOBAL_DIALOG_SCHEMA,
GlobalDialogService, GlobalDialogService,
} from '@affine/core/modules/dialogs'; } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { CloudSvg } from '@affine/core/modules/share-menu';
import { WorkspacesService } from '@affine/core/modules/workspace'; import { WorkspacesService } from '@affine/core/modules/workspace';
import { buildShowcaseWorkspace } from '@affine/core/utils/first-app-data';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import track from '@affine/track';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra'; import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { buildShowcaseWorkspace } from '../../../utils/first-app-data'; import * as styles from './index.css';
import * as styles from './dialog.css'; import { ServerSelector } from './server-selector';
interface NameWorkspaceContentProps extends ConfirmModalProps { const FormSection = ({
loading: boolean; label,
forcedCloud?: boolean; input,
serverId?: string; }: {
onConfirmName: ( label: string;
name: string, input: React.ReactNode;
workspaceFlavour: string, }) => {
avatar?: File
) => void;
}
const NameWorkspaceContent = ({
loading,
onConfirmName,
forcedCloud,
serverId,
...props
}: NameWorkspaceContentProps) => {
const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const [enable, setEnable] = useState(!!forcedCloud);
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
const globalDialogService = useService(GlobalDialogService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
const onSwitchChange = useCallback(
(checked: boolean) => {
if (loginStatus !== 'authenticated') {
return openSignInModal();
}
return setEnable(checked);
},
[loginStatus, openSignInModal]
);
const handleCreateWorkspace = useCallback(() => {
if (loginStatus !== 'authenticated' && enable) {
return openSignInModal();
}
onConfirmName(workspaceName, enable ? serverId || 'affine-cloud' : 'local');
}, [
enable,
loginStatus,
onConfirmName,
openSignInModal,
serverId,
workspaceName,
]);
const onEnter = useCallback(() => {
if (workspaceName) {
handleCreateWorkspace();
}
}, [handleCreateWorkspace, workspaceName]);
// Currently, when we create a new workspace and upload an avatar at the same time,
// an error occurs after the creation is successful: get blob 404 not found
return ( return (
<ConfirmModal <section className={styles.section}>
defaultOpen={true} <label className={styles.label}>{label}</label>
title={t['com.affine.nameWorkspace.title']()} {input}
description={t['com.affine.nameWorkspace.description']()} </section>
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
confirmText={t['com.affine.nameWorkspace.button.create']()}
confirmButtonOptions={{
variant: 'primary',
loading,
disabled: !workspaceName,
'data-testid': 'create-workspace-create-button',
}}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',
}}
onConfirm={handleCreateWorkspace}
{...props}
>
<div className={styles.avatarWrapper}>
<Avatar size={56} name={workspaceName} colorfulFallback />
</div>
<div className={styles.workspaceNameWrapper}>
<div className={styles.subTitle}>
{t['com.affine.nameWorkspace.subtitle.workspace-name']()}
</div>
<Input
autoFocus
data-testid="create-workspace-input"
onEnter={onEnter}
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
size="large"
/>
</div>
{!serverId || serverId === 'affine-cloud' ? (
<div className={styles.affineCloudWrapper}>
<div className={styles.subTitle}>{t['AFFiNE Cloud']()}</div>
<div className={styles.card}>
<div className={styles.cardText}>
<div className={styles.cardTitle}>
<span>
{t['com.affine.nameWorkspace.affine-cloud.title']()}
</span>
<Switch
checked={enable}
onChange={onSwitchChange}
disabled={forcedCloud}
/>
</div>
<div className={styles.cardDescription}>
{t['com.affine.nameWorkspace.affine-cloud.description']()}
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
{forcedCloud && BUILD_CONFIG.isWeb ? (
<a
className={styles.cloudTips}
href={BUILD_CONFIG.downloadUrl}
target="_blank"
rel="noreferrer"
>
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
</a>
) : null}
</div>
) : null}
</ConfirmModal>
); );
}; };
export const CreateWorkspaceDialog = ({ export const CreateWorkspaceDialog = ({
forcedCloud,
serverId, serverId,
close, close,
...props
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['create-workspace']>) => { }: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['create-workspace']>) => {
const workspacesService = useService(WorkspacesService); const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const [inputServerId, setInputServerId] = useState(
serverId ?? 'affine-cloud'
);
const serversService = useService(ServersService); const serversService = useService(ServersService);
const featureFlagService = useService(FeatureFlagService);
const enableLocalWorkspace = useLiveData(
featureFlagService.flags.enable_local_workspace.$
);
const server = useLiveData( const server = useLiveData(
serverId ? serversService.server$(serverId) : null inputServerId ? serversService.server$(inputServerId) : null
); );
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) close();
},
[close]
);
return (
<ConfirmModal
open
onOpenChange={onOpenChange}
title={t['com.affine.nameWorkspace.title']()}
description={t['com.affine.nameWorkspace.description']()}
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',
}}
contentOptions={{}}
childrenContentClassName={styles.content}
customConfirmButton={() => {
return (
<FrameworkScope scope={server?.scope}>
<CustomConfirmButton
workspaceName={workspaceName}
server={server}
onCreated={res =>
close({ metadata: res.meta, defaultDocId: res.defaultDocId })
}
/>
</FrameworkScope>
);
}}
{...props}
>
<FormSection
label={t['com.affine.nameWorkspace.subtitle.workspace-name']()}
input={
<RowInput
autoFocus
className={styles.input}
data-testid="create-workspace-input"
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
/>
}
/>
<FormSection
label={t['com.affine.nameWorkspace.subtitle.workspace-type']()}
input={
<ServerSelector
className={styles.select}
selectedId={inputServerId}
onChange={setInputServerId}
/>
}
/>
</ConfirmModal>
);
};
const CustomConfirmButton = ({
workspaceName,
server,
onCreated,
}: {
workspaceName: string;
server?: Server | null;
onCreated: (res: Awaited<ReturnType<typeof buildShowcaseWorkspace>>) => void;
}) => {
const t = useI18n();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const onConfirmName = useAsyncCallback( const session = useService(AuthService).session;
async (name: string, workspaceFlavour: string) => { const loginStatus = useLiveData(session.status$);
track.$.$.$.createWorkspace({ flavour: workspaceFlavour }); const globalDialogService = useService(GlobalDialogService);
const workspacesService = useService(WorkspacesService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', { server: server?.baseUrl });
}, [globalDialogService, server?.baseUrl]);
const handleConfirm = useAsyncCallback(async () => {
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
track.$.$.$.createWorkspace({
flavour: !server ? 'local' : 'affine-cloud',
});
// this will be the last step for web for now // this will be the last step for web for now
// fix me later // fix me later
try { try {
const { meta, defaultDocId } = await buildShowcaseWorkspace( const res = await buildShowcaseWorkspace(
workspacesService, workspacesService,
workspaceFlavour, server?.id ?? 'local',
name workspaceName
); );
close({ metadata: meta, defaultDocId }); onCreated(res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
notify.error({ notify.error({
@@ -198,29 +161,24 @@ export const CreateWorkspaceDialog = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, }, [loading, onCreated, server, workspaceName, workspacesService]);
[loading, workspacesService, close]
);
const onOpenChange = useCallback( const handleCheckSessionAndConfirm = useCallback(() => {
(open: boolean) => { if (server && loginStatus !== 'authenticated') {
if (!open) { return openSignInModal();
close();
} }
}, handleConfirm();
[close] }, [handleConfirm, loginStatus, openSignInModal, server]);
);
return ( return (
<FrameworkScope scope={server?.scope}> <Button
<NameWorkspaceContent disabled={!workspaceName}
data-testid="create-workspace-create-button"
variant="primary"
onClick={handleCheckSessionAndConfirm}
loading={loading} loading={loading}
serverId={serverId} >
open {t['com.affine.nameWorkspace.button.create']()}
forcedCloud={forcedCloud || !enableLocalWorkspace} </Button>
onOpenChange={onOpenChange}
onConfirmName={onConfirmName}
/>
</FrameworkScope>
); );
}; };

View File

@@ -0,0 +1,36 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const trigger = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const arrow = style({
transition: 'transform 0.2s ease',
transform: 'rotate(0deg)',
fontSize: 16,
color: cssVarV2.icon.primary,
selectors: {
'&.open': {
transform: 'rotate(180deg)',
},
},
});
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const item = style({
padding: 4,
gap: 8,
});
export const done = style({
color: cssVarV2.icon.primary,
fontSize: 20,
});

View File

@@ -0,0 +1,147 @@
import { Menu, MenuItem } from '@affine/component';
import { type Server, ServersService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
CloudWorkspaceIcon,
DoneIcon,
LocalWorkspaceIcon,
SelfhostIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type HTMLAttributes,
type ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import * as styles from './server-selector.css';
export interface ServerSelectorProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
selectedId: Server['id'];
onChange: (id: Server['id']) => void;
placeholder?: ReactNode;
}
export const ServerSelector = ({
selectedId,
onChange,
placeholder,
className,
...props
}: ServerSelectorProps) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const serversService = useService(ServersService);
const servers = useLiveData(serversService.servers$);
const selectedServer = useMemo(() => {
return servers.find(s => s.id === selectedId);
}, [selectedId, servers]);
const serverName = useLiveData(
selectedServer?.config$.selector(c => c.serverName)
);
const selectedServerName =
selectedId === 'local'
? t['com.affine.workspaceList.workspaceListType.local']()
: serverName;
return (
<Menu
rootOptions={{
open,
onOpenChange: setOpen,
}}
contentOptions={{
style: {
maxWidth: 432,
width: 'calc(100dvw - 68px)',
},
}}
items={
<ul className={styles.list} data-testid="server-selector-list">
<LocalSelectorItem
onSelect={onChange}
active={selectedId === 'local'}
/>
{servers.map(server => (
<ServerSelectorItem
key={server.id}
server={server}
onSelect={onChange}
active={selectedId === server.id}
/>
))}
</ul>
}
>
<div
data-testid="server-selector-trigger"
className={clsx(styles.trigger, className)}
{...props}
>
{selectedServerName ?? placeholder}
<ArrowDownSmallIcon className={clsx(styles.arrow, { open })} />
</div>
</Menu>
);
};
const LocalSelectorItem = ({
onSelect,
active,
}: {
onSelect?: (id: string) => void;
active?: boolean;
}) => {
const t = useI18n();
const handleSelect = useCallback(() => {
onSelect?.('local');
}, [onSelect]);
return (
<MenuItem
data-testid="local"
className={styles.item}
prefixIcon={<LocalWorkspaceIcon />}
onClick={handleSelect}
suffixIcon={active ? <DoneIcon className={styles.done} /> : null}
>
{t['com.affine.workspaceList.workspaceListType.local']()}
</MenuItem>
);
};
const ServerSelectorItem = ({
server,
onSelect,
active,
}: {
server: Server;
onSelect?: (id: string) => void;
active?: boolean;
}) => {
const name = useLiveData(server.config$.selector(c => c.serverName));
const Icon = server.id === 'affine-cloud' ? CloudWorkspaceIcon : SelfhostIcon;
const handleSelect = useCallback(() => {
onSelect?.(server.id);
}, [onSelect, server.id]);
return (
<MenuItem
data-testid={server.id}
className={styles.item}
prefixIcon={<Icon />}
onClick={handleSelect}
suffixIcon={active ? <DoneIcon className={styles.done} /> : null}
>
{name}
</MenuItem>
);
};

View File

@@ -186,6 +186,14 @@ const Dialog = ({
className={styles.workspaceSelector} className={styles.workspaceSelector}
showArrowDownIcon showArrowDownIcon
disable={disabled} disable={disabled}
menuContentOptions={{
side: 'top',
style: {
maxHeight: 'min(600px, calc(50vh + 50px))',
width: 352,
maxWidth: 'calc(100vw - 20px)',
},
}}
/> />
</> </>
)} )}

View File

@@ -1,7 +1,7 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const fallback = style({ export const fallback = style({
padding: '4px 20px', padding: '4px 16px',
height: '100%', height: '100%',
overflow: 'clip', overflow: 'clip',
}); });

View File

@@ -15,7 +15,7 @@ export type SettingTab =
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`; | `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`;
export type GLOBAL_DIALOG_SCHEMA = { export type GLOBAL_DIALOG_SCHEMA = {
'create-workspace': (props: { serverId?: string; forcedCloud?: boolean }) => { 'create-workspace': (props: { serverId?: string }) => {
metadata: WorkspaceMetadata; metadata: WorkspaceMetadata;
defaultDocId?: string; defaultDocId?: string;
}; };

View File

@@ -666,6 +666,7 @@
"com.affine.nameWorkspace.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.", "com.affine.nameWorkspace.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
"com.affine.nameWorkspace.placeholder": "Set a workspace name", "com.affine.nameWorkspace.placeholder": "Set a workspace name",
"com.affine.nameWorkspace.subtitle.workspace-name": "Workspace name", "com.affine.nameWorkspace.subtitle.workspace-name": "Workspace name",
"com.affine.nameWorkspace.subtitle.workspace-type": "Workspace type",
"com.affine.nameWorkspace.title": "Name your workspace", "com.affine.nameWorkspace.title": "Name your workspace",
"com.affine.new.page-mode": "New page", "com.affine.new.page-mode": "New page",
"com.affine.new_edgeless": "New edgeless", "com.affine.new_edgeless": "New edgeless",

View File

@@ -3,10 +3,8 @@ import {
clickNewPageButton, clickNewPageButton,
getBlockSuiteEditorTitle, getBlockSuiteEditorTitle,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { import { clickSideBarSettingButton } from '@affine-test/kit/utils/sidebar';
clickSideBarCurrentWorkspaceBanner, import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
clickSideBarSettingButton,
} from '@affine-test/kit/utils/sidebar';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
@@ -112,13 +110,7 @@ test('windows only check', async ({ page }) => {
test('delete workspace', async ({ page }) => { test('delete workspace', async ({ page }) => {
await clickNewPageButton(page); await clickNewPageButton(page);
await clickSideBarCurrentWorkspaceBanner(page); await createLocalWorkspace({ name: 'Delete Me' }, page);
await page.getByTestId('new-workspace').click();
await page.getByTestId('create-workspace-input').fill('Delete Me');
await page.getByTestId('create-workspace-create-button').click();
// await page.getByTestId('create-workspace-continue-button').click({
// delay: 100,
// });
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await clickSideBarSettingButton(page); await clickSideBarSettingButton(page);
await page.getByTestId('workspace-setting:preference').click(); await page.getByTestId('workspace-setting:preference').click();

View File

@@ -7,6 +7,7 @@ import {
clickNewPageButton, clickNewPageButton,
clickSideBarCurrentWorkspaceBanner, clickSideBarCurrentWorkspaceBanner,
} from '@affine-test/kit/utils/sidebar'; } from '@affine-test/kit/utils/sidebar';
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import fs from 'fs-extra'; import fs from 'fs-extra';
@@ -106,12 +107,8 @@ test('export then add', async ({ page, appInfo, workspace }) => {
test('delete workspace and then restore it from backup', async ({ page }) => { test('delete workspace and then restore it from backup', async ({ page }) => {
//#region 1. create a new workspace //#region 1. create a new workspace
await clickSideBarCurrentWorkspaceBanner(page);
const newWorkspaceName = 'new-test-name'; const newWorkspaceName = 'new-test-name';
await createLocalWorkspace({ name: newWorkspaceName }, page);
await page.getByTestId('new-workspace').click();
await page.getByTestId('create-workspace-input').fill(newWorkspaceName);
await page.getByTestId('create-workspace-create-button').click();
//#endregion //#endregion
//#region 2. create a page in the new workspace (will verify later if it is successfully recovered) //#region 2. create a page in the new workspace (will verify later if it is successfully recovered)

View File

@@ -4,6 +4,7 @@ import {
clickNewPageButton, clickNewPageButton,
waitForEditorLoad, waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
test('should create a page with a local first avatar and remove it', async ({ test('should create a page with a local first avatar and remove it', async ({
@@ -13,10 +14,7 @@ test('should create a page with a local first avatar and remove it', async ({
await openHomePage(page); await openHomePage(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);
await clickNewPageButton(page); await clickNewPageButton(page);
await page.getByTestId('workspace-name').click(); await createLocalWorkspace({ name: 'Test Workspace 1' }, page);
await page.getByTestId('new-workspace').click();
await page.getByTestId('create-workspace-input').fill('Test Workspace 1');
await page.getByTestId('create-workspace-create-button').click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.getByTestId('workspace-name').click(); await page.getByTestId('workspace-name').click();
await page await page

View File

@@ -5,21 +5,13 @@ import {
openSettingModal, openSettingModal,
openWorkspaceSettingPanel, openWorkspaceSettingPanel,
} from '@affine-test/kit/utils/setting'; } from '@affine-test/kit/utils/setting';
import { clickSideBarCurrentWorkspaceBanner } from '@affine-test/kit/utils/sidebar'; import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
test('Create new workspace, then delete it', async ({ page, workspace }) => { test('Create new workspace, then delete it', async ({ page, workspace }) => {
await openHomePage(page); await openHomePage(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);
await clickSideBarCurrentWorkspaceBanner(page); await createLocalWorkspace({ name: 'Test Workspace' }, page);
await page.getByTestId('new-workspace').click();
await page.waitForTimeout(1000);
await page
.getByTestId('create-workspace-input')
.pressSequentially('Test Workspace', { delay: 50 });
const createButton = page.getByTestId('create-workspace-create-button');
await createButton.click();
await createButton.waitFor({ state: 'hidden' });
await page.waitForSelector('[data-testid="workspace-name"]'); await page.waitForSelector('[data-testid="workspace-name"]');
expect(await page.getByTestId('workspace-name').textContent()).toBe( expect(await page.getByTestId('workspace-name').textContent()).toBe(
@@ -72,11 +64,7 @@ test('Delete last workspace', async ({ page }) => {
await page.getByTestId('delete-workspace-confirm-button').click(); await page.getByTestId('delete-workspace-confirm-button').click();
await openHomePage(page); await openHomePage(page);
await expect(page.getByTestId('new-workspace')).toBeVisible(); await expect(page.getByTestId('new-workspace')).toBeVisible();
await page.getByTestId('new-workspace').click(); await createLocalWorkspace({ name: 'Test Workspace' }, page, true);
await page
.locator('[data-testid="create-workspace-input"]')
.pressSequentially('Test Workspace');
await page.getByTestId('create-workspace-create-button').click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.waitForSelector('[data-testid="workspace-name"]'); await page.waitForSelector('[data-testid="workspace-name"]');
await expect(page.getByTestId('workspace-name')).toHaveText('Test Workspace'); await expect(page.getByTestId('workspace-name')).toHaveText('Test Workspace');

View File

@@ -15,9 +15,12 @@ export async function openWorkspaceListModal(page: Page) {
export async function createLocalWorkspace( export async function createLocalWorkspace(
params: CreateWorkspaceParams, params: CreateWorkspaceParams,
page: Page page: Page,
skipOpenWorkspaceListModal = false
) { ) {
if (!skipOpenWorkspaceListModal) {
await openWorkspaceListModal(page); await openWorkspaceListModal(page);
}
// open create workspace modal // open create workspace modal
await page.getByTestId('new-workspace').click(); await page.getByTestId('new-workspace').click();
@@ -30,6 +33,11 @@ export async function createLocalWorkspace(
await page.getByPlaceholder('Set a Workspace name').click(); await page.getByPlaceholder('Set a Workspace name').click();
await page.getByPlaceholder('Set a Workspace name').fill(params.name); await page.getByPlaceholder('Set a Workspace name').fill(params.name);
// select local server
await page.getByTestId('server-selector-trigger').click();
const serverSelectorList = page.getByTestId('server-selector-list');
await serverSelectorList.getByTestId('local').click();
// click create button // click create button
await page.getByTestId('create-workspace-create-button').click({ await page.getByTestId('create-workspace-create-button').click({
delay: 500, delay: 500,