feat(core): add split view to experimental features settings (#6093)

This commit is contained in:
Peng Xiao
2024-03-14 05:13:04 +00:00
parent 05583dbe98
commit dd9a253772
12 changed files with 157 additions and 36 deletions

View File

@@ -115,4 +115,10 @@ export class FeatureManagementService {
async listFeatureWorkspaces(feature: FeatureType) { async listFeatureWorkspaces(feature: FeatureType) {
return this.feature.listFeatureWorkspaces(feature); return this.feature.listFeatureWorkspaces(feature);
} }
async getUserFeatures(userId: string): Promise<FeatureType[]> {
return (await this.feature.getUserFeatures(userId)).map(
f => f.feature.name
);
}
} }

View File

@@ -21,7 +21,7 @@ import {
import { CurrentUser } from '../auth/current-user'; import { CurrentUser } from '../auth/current-user';
import { Public } from '../auth/guard'; import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service'; import { sessionUser } from '../auth/service';
import { FeatureManagementService } from '../features'; import { FeatureManagementService, FeatureType } from '../features';
import { QuotaService } from '../quota'; import { QuotaService } from '../quota';
import { AvatarStorage } from '../storage'; import { AvatarStorage } from '../storage';
import { UserService } from './service'; import { UserService } from './service';
@@ -108,6 +108,15 @@ export class UserResolver {
}); });
} }
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => [FeatureType], {
name: 'features',
description: 'Enabled features of a user',
})
async userFeatures(@CurrentUser() user: CurrentUser) {
return this.feature.getUserFeatures(user.id);
}
@Throttle({ @Throttle({
default: { default: {
limit: 10, limit: 10,

View File

@@ -374,6 +374,9 @@ type UserType {
"""User email verified""" """User email verified"""
emailVerified: Boolean! emailVerified: Boolean!
"""Enabled features of a user"""
features: [FeatureType!]!
"""User password has been set""" """User password has been set"""
hasPassword: Boolean hasPassword: Boolean
id: ID! id: ID!

View File

@@ -25,6 +25,7 @@ export type AppSetting = {
enableNoisyBackground: boolean; enableNoisyBackground: boolean;
autoCheckUpdate: boolean; autoCheckUpdate: boolean;
autoDownloadUpdate: boolean; autoDownloadUpdate: boolean;
enableMultiView: boolean;
}; };
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [ export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
'frameless', 'frameless',
@@ -63,6 +64,7 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
enableNoisyBackground: true, enableNoisyBackground: true,
autoCheckUpdate: true, autoCheckUpdate: true,
autoDownloadUpdate: true, autoDownloadUpdate: true,
enableMultiView: false,
}); });
type SetStateAction<Value> = Value | ((prev: Value) => Value); type SetStateAction<Value> = Value | ((prev: Value) => Value);

View File

@@ -5,11 +5,10 @@ import {
import { Avatar } from '@affine/component/ui/avatar'; import { Avatar } from '@affine/component/ui/avatar';
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useIsEarlyAccess } from '@affine/core/hooks/affine/use-user-features';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { useWorkspaceAvailableFeatures } from '@affine/core/hooks/use-workspace-features';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Logo1Icon } from '@blocksuite/icons'; import { Logo1Icon } from '@blocksuite/icons';
import { import {
@@ -253,7 +252,7 @@ const WorkspaceListItem = ({
const isCurrent = currentWorkspace.id === meta.id; const isCurrent = currentWorkspace.id === meta.id;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const isOwner = useIsWorkspaceOwner(meta); const isOwner = useIsWorkspaceOwner(meta);
const availableFeatures = useWorkspaceAvailableFeatures(meta); const isEarlyAccess = useIsEarlyAccess();
const onClickPreference = useCallback(() => { const onClickPreference = useCallback(() => {
onClick('preference'); onClick('preference');
@@ -263,11 +262,7 @@ const WorkspaceListItem = ({
return subTabConfigs return subTabConfigs
.filter(({ key }) => { .filter(({ key }) => {
if (key === 'experimental-features') { if (key === 'experimental-features') {
return ( return isOwner && isEarlyAccess;
isOwner &&
meta.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
availableFeatures.length > 0
);
} }
return true; return true;
}) })
@@ -287,14 +282,7 @@ const WorkspaceListItem = ({
</div> </div>
); );
}); });
}, [ }, [activeSubTab, isEarlyAccess, isOwner, onClick, t]);
activeSubTab,
availableFeatures.length,
isOwner,
meta.flavour,
onClick,
t,
]);
return ( return (
<> <>

View File

@@ -1,5 +1,6 @@
import { Button, Checkbox, Loading, Switch } from '@affine/component'; import { Button, Checkbox, Loading, Switch } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components'; import { SettingHeader } from '@affine/component/setting-components';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { import {
useSetWorkspaceFeature, useSetWorkspaceFeature,
@@ -79,6 +80,29 @@ interface ExperimentalFeaturesItemProps {
} }
const ExperimentalFeaturesItem = ({ const ExperimentalFeaturesItem = ({
title,
isMutating,
checked,
onChange,
}: {
title: React.ReactNode;
isMutating?: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
}) => {
return (
<div className={styles.switchRow}>
{title}
<Switch
checked={checked}
onChange={onChange}
className={isMutating ? styles.switchDisabled : ''}
/>
</div>
);
};
const WorkspaceFeaturesSettingItem = ({
feature, feature,
title, title,
workspaceMetadata, workspaceMetadata,
@@ -96,14 +120,51 @@ const ExperimentalFeaturesItem = ({
); );
return ( return (
<div className={styles.switchRow}> <ExperimentalFeaturesItem
{title} title={title}
<Switch isMutating={isMutating}
checked={localEnabled} checked={localEnabled}
onChange={onChange} onChange={onChange}
className={isMutating ? styles.switchDisabled : ''}
/> />
</div> );
};
const CopilotSettingRow = ({
workspaceMetadata,
}: {
workspaceMetadata: WorkspaceMetadata;
}) => {
const features = useWorkspaceAvailableFeatures(workspaceMetadata);
return features.includes(FeatureType.Copilot) ? (
<WorkspaceFeaturesSettingItem
title="AI POC"
workspaceMetadata={workspaceMetadata}
feature={FeatureType.Copilot}
/>
) : null;
};
const SplitViewSettingRow = () => {
const { appSettings, updateSettings } = useAppSettingHelper();
const onToggle = useCallback(
(checked: boolean) => {
updateSettings('enableMultiView', checked);
},
[updateSettings]
);
if (!environment.isDesktop) {
return null; // only enable on desktop
}
return (
<ExperimentalFeaturesItem
title="Split View"
checked={appSettings.enableMultiView}
onChange={onToggle}
/>
); );
}; };
@@ -113,7 +174,6 @@ const ExperimentalFeaturesMain = ({
workspaceMetadata: WorkspaceMetadata; workspaceMetadata: WorkspaceMetadata;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const features = useWorkspaceAvailableFeatures(workspaceMetadata);
return ( return (
<> <>
@@ -122,14 +182,8 @@ const ExperimentalFeaturesMain = ({
'com.affine.settings.workspace.experimental-features.header.plugins' 'com.affine.settings.workspace.experimental-features.header.plugins'
]()} ]()}
/> />
<CopilotSettingRow workspaceMetadata={workspaceMetadata} />
{features.includes(FeatureType.Copilot) ? ( <SplitViewSettingRow />
<ExperimentalFeaturesItem
title="AI POC"
workspaceMetadata={workspaceMetadata}
feature={FeatureType.Copilot}
/>
) : null}
</> </>
); );
}; };

View File

@@ -0,0 +1,24 @@
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
const wrappedFetcher = (fetcher: BareFetcher<any> | null, ...args: any[]) =>
fetcher?.(...args).catch(() => null);
const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config);
};
export function useIsEarlyAccess() {
const { data } = useQueryImmutable(
{
query: getUserFeaturesQuery,
},
{
use: [errorHandler],
}
);
return data?.currentUser?.features.includes(FeatureType.EarlyAccess) ?? false;
}

View File

@@ -1,3 +1,4 @@
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useService } from '@toeverything/infra/di'; import { useService } from '@toeverything/infra/di';
import type { To } from 'history'; import type { To } from 'history';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -13,12 +14,13 @@ export const WorkbenchLink = ({
{ to: To } & React.HTMLProps<HTMLAnchorElement> { to: To } & React.HTMLProps<HTMLAnchorElement>
>) => { >) => {
const workbench = useService(Workbench); const workbench = useService(Workbench);
const { appSettings } = useAppSettingHelper();
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => { (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); event.preventDefault();
// TODO: open this when multi view control is implemented // TODO: open this when multi view control is implemented
if ( if (
(window as any).enableMultiView && appSettings.enableMultiView &&
environment.isDesktop && environment.isDesktop &&
(event.ctrlKey || event.metaKey) (event.ctrlKey || event.metaKey)
) { ) {
@@ -29,7 +31,7 @@ export const WorkbenchLink = ({
onClick?.(event); onClick?.(event);
}, },
[onClick, to, workbench] [appSettings.enableMultiView, onClick, to, workbench]
); );
return ( return (
<a {...other} href="#" onClick={handleClick}> <a {...other} href="#" onClick={handleClick}>

View File

@@ -0,0 +1,5 @@
query getUserFeatures {
currentUser {
features
}
}

View File

@@ -343,6 +343,19 @@ query getPublicWorkspace($id: String!) {
}`, }`,
}; };
export const getUserFeaturesQuery = {
id: 'getUserFeaturesQuery' as const,
operationName: 'getUserFeatures',
definitionName: 'currentUser',
containsFile: false,
query: `
query getUserFeatures {
currentUser {
features
}
}`,
};
export const getUserQuery = { export const getUserQuery = {
id: 'getUserQuery' as const, id: 'getUserQuery' as const,
operationName: 'getUser', operationName: 'getUser',

View File

@@ -387,6 +387,13 @@ export type GetPublicWorkspaceQuery = {
publicWorkspace: { __typename?: 'WorkspaceType'; id: string }; publicWorkspace: { __typename?: 'WorkspaceType'; id: string };
}; };
export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>;
export type GetUserFeaturesQuery = {
__typename?: 'Query';
currentUser: { __typename?: 'UserType'; features: Array<FeatureType> } | null;
};
export type GetUserQueryVariables = Exact<{ export type GetUserQueryVariables = Exact<{
email: Scalars['String']['input']; email: Scalars['String']['input'];
}>; }>;
@@ -953,6 +960,11 @@ export type Queries =
variables: GetPublicWorkspaceQueryVariables; variables: GetPublicWorkspaceQueryVariables;
response: GetPublicWorkspaceQuery; response: GetPublicWorkspaceQuery;
} }
| {
name: 'getUserFeaturesQuery';
variables: GetUserFeaturesQueryVariables;
response: GetUserFeaturesQuery;
}
| { | {
name: 'getUserQuery'; name: 'getUserQuery';
variables: GetUserQueryVariables; variables: GetUserQueryVariables;

View File

@@ -24,6 +24,9 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => {
await openSettingModal(page); await openSettingModal(page);
await openWorkspaceSettingPanel(page, 'Test Workspace'); await openWorkspaceSettingPanel(page, 'Test Workspace');
await page.getByTestId('delete-workspace-button').click(); await page.getByTestId('delete-workspace-button').click();
await expect(
page.getByTestId('affine-notification').first()
).not.toBeVisible();
const workspaceNameDom = page.getByTestId('workspace-name'); const workspaceNameDom = page.getByTestId('workspace-name');
const currentWorkspaceName = (await workspaceNameDom.evaluate( const currentWorkspaceName = (await workspaceNameDom.evaluate(
node => node.textContent node => node.textContent