From dd9a253772a6151e60eaf56175162d4d7716fefe Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Thu, 14 Mar 2024 05:13:04 +0000 Subject: [PATCH] feat(core): add split view to experimental features settings (#6093) --- .../server/src/core/features/management.ts | 6 ++ .../backend/server/src/core/user/resolver.ts | 11 ++- packages/backend/server/src/schema.gql | 3 + packages/common/infra/src/atom/settings.ts | 2 + .../setting-modal/setting-sidebar/index.tsx | 20 +---- .../experimental-features/index.tsx | 88 +++++++++++++++---- .../src/hooks/affine/use-user-features.ts | 24 +++++ .../modules/workbench/view/workbench-link.tsx | 6 +- .../graphql/src/graphql/get-user-features.gql | 5 ++ .../frontend/graphql/src/graphql/index.ts | 13 +++ packages/frontend/graphql/src/schema.ts | 12 +++ .../e2e/local-first-delete-workspace.spec.ts | 3 + 12 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 packages/frontend/core/src/hooks/affine/use-user-features.ts create mode 100644 packages/frontend/graphql/src/graphql/get-user-features.gql diff --git a/packages/backend/server/src/core/features/management.ts b/packages/backend/server/src/core/features/management.ts index 3176e4d7d6..c5df3713d1 100644 --- a/packages/backend/server/src/core/features/management.ts +++ b/packages/backend/server/src/core/features/management.ts @@ -115,4 +115,10 @@ export class FeatureManagementService { async listFeatureWorkspaces(feature: FeatureType) { return this.feature.listFeatureWorkspaces(feature); } + + async getUserFeatures(userId: string): Promise { + return (await this.feature.getUserFeatures(userId)).map( + f => f.feature.name + ); + } } diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 1b4f878eeb..d877a154dd 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -21,7 +21,7 @@ import { import { CurrentUser } from '../auth/current-user'; import { Public } from '../auth/guard'; import { sessionUser } from '../auth/service'; -import { FeatureManagementService } from '../features'; +import { FeatureManagementService, FeatureType } from '../features'; import { QuotaService } from '../quota'; import { AvatarStorage } from '../storage'; 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({ default: { limit: 10, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index cfb163f47f..2d18df9e27 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -374,6 +374,9 @@ type UserType { """User email verified""" emailVerified: Boolean! + """Enabled features of a user""" + features: [FeatureType!]! + """User password has been set""" hasPassword: Boolean id: ID! diff --git a/packages/common/infra/src/atom/settings.ts b/packages/common/infra/src/atom/settings.ts index ac66005dc3..d22cdf9186 100644 --- a/packages/common/infra/src/atom/settings.ts +++ b/packages/common/infra/src/atom/settings.ts @@ -25,6 +25,7 @@ export type AppSetting = { enableNoisyBackground: boolean; autoCheckUpdate: boolean; autoDownloadUpdate: boolean; + enableMultiView: boolean; }; export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [ 'frameless', @@ -63,6 +64,7 @@ const appSettingBaseAtom = atomWithStorage('affine-settings', { enableNoisyBackground: true, autoCheckUpdate: true, autoDownloadUpdate: true, + enableMultiView: false, }); type SetStateAction = Value | ((prev: Value) => Value); diff --git a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index d3467a3339..0b3a2af2e4 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -5,11 +5,10 @@ import { import { Avatar } from '@affine/component/ui/avatar'; import { Tooltip } from '@affine/component/ui/tooltip'; 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 { useWorkspaceAvailableFeatures } from '@affine/core/hooks/use-workspace-features'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; import { @@ -253,7 +252,7 @@ const WorkspaceListItem = ({ const isCurrent = currentWorkspace.id === meta.id; const t = useAFFiNEI18N(); const isOwner = useIsWorkspaceOwner(meta); - const availableFeatures = useWorkspaceAvailableFeatures(meta); + const isEarlyAccess = useIsEarlyAccess(); const onClickPreference = useCallback(() => { onClick('preference'); @@ -263,11 +262,7 @@ const WorkspaceListItem = ({ return subTabConfigs .filter(({ key }) => { if (key === 'experimental-features') { - return ( - isOwner && - meta.flavour === WorkspaceFlavour.AFFINE_CLOUD && - availableFeatures.length > 0 - ); + return isOwner && isEarlyAccess; } return true; }) @@ -287,14 +282,7 @@ const WorkspaceListItem = ({ ); }); - }, [ - activeSubTab, - availableFeatures.length, - isOwner, - meta.flavour, - onClick, - t, - ]); + }, [activeSubTab, isEarlyAccess, isOwner, onClick, t]); return ( <> diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx index 07579c18f9..bc11af3f26 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx @@ -1,5 +1,6 @@ import { Button, Checkbox, Loading, Switch } from '@affine/component'; 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 { useSetWorkspaceFeature, @@ -79,6 +80,29 @@ interface ExperimentalFeaturesItemProps { } const ExperimentalFeaturesItem = ({ + title, + isMutating, + checked, + onChange, +}: { + title: React.ReactNode; + isMutating?: boolean; + checked: boolean; + onChange: (checked: boolean) => void; +}) => { + return ( +
+ {title} + +
+ ); +}; + +const WorkspaceFeaturesSettingItem = ({ feature, title, workspaceMetadata, @@ -96,14 +120,51 @@ const ExperimentalFeaturesItem = ({ ); return ( -
- {title} - -
+ + ); +}; + +const CopilotSettingRow = ({ + workspaceMetadata, +}: { + workspaceMetadata: WorkspaceMetadata; +}) => { + const features = useWorkspaceAvailableFeatures(workspaceMetadata); + + return features.includes(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 ( + ); }; @@ -113,7 +174,6 @@ const ExperimentalFeaturesMain = ({ workspaceMetadata: WorkspaceMetadata; }) => { const t = useAFFiNEI18N(); - const features = useWorkspaceAvailableFeatures(workspaceMetadata); return ( <> @@ -122,14 +182,8 @@ const ExperimentalFeaturesMain = ({ 'com.affine.settings.workspace.experimental-features.header.plugins' ]()} /> - - {features.includes(FeatureType.Copilot) ? ( - - ) : null} + + ); }; diff --git a/packages/frontend/core/src/hooks/affine/use-user-features.ts b/packages/frontend/core/src/hooks/affine/use-user-features.ts new file mode 100644 index 0000000000..505e04a2ff --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-user-features.ts @@ -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 | 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; +} diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 9ad49879ce..af4fe673c3 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -1,3 +1,4 @@ +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useService } from '@toeverything/infra/di'; import type { To } from 'history'; import { useCallback } from 'react'; @@ -13,12 +14,13 @@ export const WorkbenchLink = ({ { to: To } & React.HTMLProps >) => { const workbench = useService(Workbench); + const { appSettings } = useAppSettingHelper(); const handleClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); // TODO: open this when multi view control is implemented if ( - (window as any).enableMultiView && + appSettings.enableMultiView && environment.isDesktop && (event.ctrlKey || event.metaKey) ) { @@ -29,7 +31,7 @@ export const WorkbenchLink = ({ onClick?.(event); }, - [onClick, to, workbench] + [appSettings.enableMultiView, onClick, to, workbench] ); return ( diff --git a/packages/frontend/graphql/src/graphql/get-user-features.gql b/packages/frontend/graphql/src/graphql/get-user-features.gql new file mode 100644 index 0000000000..5c0cc29f78 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-user-features.gql @@ -0,0 +1,5 @@ +query getUserFeatures { + currentUser { + features + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index d28ef61e64..6c1acd4517 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -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 = { id: 'getUserQuery' as const, operationName: 'getUser', diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 903dcf52a6..2675c9ed6a 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -387,6 +387,13 @@ export type GetPublicWorkspaceQuery = { publicWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; +export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>; + +export type GetUserFeaturesQuery = { + __typename?: 'Query'; + currentUser: { __typename?: 'UserType'; features: Array } | null; +}; + export type GetUserQueryVariables = Exact<{ email: Scalars['String']['input']; }>; @@ -953,6 +960,11 @@ export type Queries = variables: GetPublicWorkspaceQueryVariables; response: GetPublicWorkspaceQuery; } + | { + name: 'getUserFeaturesQuery'; + variables: GetUserFeaturesQueryVariables; + response: GetUserFeaturesQuery; + } | { name: 'getUserQuery'; variables: GetUserQueryVariables; diff --git a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts index faeb92bbe5..46f50b791b 100644 --- a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts @@ -24,6 +24,9 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => { await openSettingModal(page); await openWorkspaceSettingPanel(page, 'Test Workspace'); await page.getByTestId('delete-workspace-button').click(); + await expect( + page.getByTestId('affine-notification').first() + ).not.toBeVisible(); const workspaceNameDom = page.getByTestId('workspace-name'); const currentWorkspaceName = (await workspaceNameDom.evaluate( node => node.textContent