feat(core): add split view to experimental features settings (#6093)
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
24
packages/frontend/core/src/hooks/affine/use-user-features.ts
Normal file
24
packages/frontend/core/src/hooks/affine/use-user-features.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
query getUserFeatures {
|
||||||
|
currentUser {
|
||||||
|
features
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user