mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-05-08 22:07:32 +08:00
feat: redirect account click & OAuth to Calendar settings (#14693)
### PR Description * clicking a linked calendar account now switches settings to Workspace Integrations and opens the Calendar settings directly * calendar OAuth returns now land on Workspace Integrations with the Calendar settings opened instead of the homepage * Improves UX by reducing friction when managing calendar integrations https://www.loom.com/share/49fa5c448ce049659877beb42d7bd81a <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Calendar integration settings can now be opened automatically (including from OAuth redirects) and workspace settings support a scroll-to-anchor. * Integration account rows are now clickable for quick access to settings. * **Improvements** * Enhanced visual feedback with interactive hover and focus states for integration controls. * **Tests** * Added tests covering the OAuth redirect behavior and workspace settings scroll/open handling. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkspaceSettingsPath,
|
||||
buildWorkspaceSettingsRedirectUri,
|
||||
} from '../use-navigate-helper';
|
||||
|
||||
describe('use-navigate-helper utilities', () => {
|
||||
test('buildWorkspaceSettingsPath includes tab and scroll anchor', () => {
|
||||
expect(
|
||||
buildWorkspaceSettingsPath('workspace-1', {
|
||||
tab: 'workspace:integrations',
|
||||
scrollAnchor: 'integration-calendar',
|
||||
})
|
||||
).toBe(
|
||||
'/workspace/workspace-1/settings?tab=workspace%3Aintegrations&scrollAnchor=integration-calendar'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWorkspaceSettingsRedirectUri builds a settings redirect from a workspace page', () => {
|
||||
expect(
|
||||
buildWorkspaceSettingsRedirectUri(
|
||||
'https://app.affine.pro/workspace/workspace-1/all',
|
||||
{
|
||||
tab: 'workspace:integrations',
|
||||
scrollAnchor: 'integration-calendar',
|
||||
}
|
||||
)
|
||||
).toBe(
|
||||
'https://app.affine.pro/workspace/workspace-1/settings?tab=workspace%3Aintegrations&scrollAnchor=integration-calendar'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWorkspaceSettingsRedirectUri preserves app subpaths before the workspace route', () => {
|
||||
expect(
|
||||
buildWorkspaceSettingsRedirectUri(
|
||||
'https://app.affine.pro/app/workspace/workspace-1/collection',
|
||||
{
|
||||
tab: 'workspace:integrations',
|
||||
scrollAnchor: 'integration-calendar',
|
||||
}
|
||||
)
|
||||
).toBe(
|
||||
'https://app.affine.pro/app/workspace/workspace-1/settings?tab=workspace%3Aintegrations&scrollAnchor=integration-calendar'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWorkspaceSettingsRedirectUri falls back to the current url when no workspace route is present', () => {
|
||||
expect(
|
||||
buildWorkspaceSettingsRedirectUri('https://app.affine.pro/sign-in', {
|
||||
tab: 'workspace:integrations',
|
||||
})
|
||||
).toBe('https://app.affine.pro/sign-in');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,58 @@ export enum RouteLogic {
|
||||
PUSH = 'push',
|
||||
}
|
||||
|
||||
export type WorkspaceSettingsRouteOptions = {
|
||||
tab?: SettingTab;
|
||||
scrollAnchor?: string;
|
||||
};
|
||||
|
||||
export function buildWorkspaceSettingsPath(
|
||||
workspaceId: string,
|
||||
options?: WorkspaceSettingsRouteOptions
|
||||
) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (options?.tab) {
|
||||
searchParams.set('tab', options.tab);
|
||||
}
|
||||
if (options?.scrollAnchor) {
|
||||
searchParams.set('scrollAnchor', options.scrollAnchor);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
return `/workspace/${workspaceId}/settings${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceSettingsRedirectUri(
|
||||
currentHref: string,
|
||||
options?: WorkspaceSettingsRouteOptions
|
||||
): string {
|
||||
let currentUrl: URL;
|
||||
try {
|
||||
currentUrl = new URL(currentHref);
|
||||
} catch {
|
||||
return currentHref;
|
||||
}
|
||||
|
||||
const pathSegments = currentUrl.pathname.split('/').filter(Boolean);
|
||||
const workspaceSegmentIndex = pathSegments.indexOf('workspace');
|
||||
const workspaceId = pathSegments[workspaceSegmentIndex + 1];
|
||||
|
||||
if (workspaceSegmentIndex === -1 || !workspaceId) {
|
||||
return currentHref;
|
||||
}
|
||||
|
||||
const basePath = pathSegments.slice(0, workspaceSegmentIndex).join('/');
|
||||
const redirectUrl = new URL(
|
||||
buildWorkspaceSettingsPath(workspaceId, options),
|
||||
currentUrl.origin
|
||||
);
|
||||
|
||||
if (basePath) {
|
||||
redirectUrl.pathname = `/${basePath}${redirectUrl.pathname}`;
|
||||
}
|
||||
|
||||
return redirectUrl.toString();
|
||||
}
|
||||
|
||||
// TODO(@eyhn): add a name -> path helper in the results
|
||||
/**
|
||||
* Use this for over workbench navigate, for navigate in workbench, use `WorkbenchService`.
|
||||
@@ -213,18 +265,15 @@ export function useNavigateHelper() {
|
||||
const jumpToWorkspaceSettings = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
tab?: SettingTab,
|
||||
options?: WorkspaceSettingsRouteOptions | SettingTab,
|
||||
logic: RouteLogic = RouteLogic.PUSH
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tab) {
|
||||
searchParams.set('tab', tab);
|
||||
}
|
||||
const resolvedOptions =
|
||||
typeof options === 'string' ? { tab: options } : options;
|
||||
|
||||
return navigate(
|
||||
`/workspace/${workspaceId}/settings?${searchParams.toString()}`,
|
||||
{
|
||||
replace: logic === RouteLogic.REPLACE,
|
||||
}
|
||||
buildWorkspaceSettingsPath(workspaceId, resolvedOptions),
|
||||
{ replace: logic === RouteLogic.REPLACE }
|
||||
);
|
||||
},
|
||||
[navigate]
|
||||
|
||||
@@ -242,7 +242,7 @@ export const AccountSetting = ({
|
||||
{serverFeatures?.copilot && (
|
||||
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
|
||||
)}
|
||||
<IntegrationsPanel />
|
||||
<IntegrationsPanel onChangeSettingState={onChangeSettingState} />
|
||||
<SettingRow
|
||||
name={t[`Sign out`]()}
|
||||
desc={t['com.affine.setting.sign.out.message']()}
|
||||
|
||||
@@ -46,6 +46,20 @@ export const accountRow = style({
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
background: cssVarV2.layer.background.primary,
|
||||
transition: 'background-color 0.15s ease, border-color 0.15s ease',
|
||||
selectors: {
|
||||
'&[data-interactive="true"]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[data-interactive="true"]:hover': {
|
||||
background: cssVarV2.layer.background.hoverOverlay,
|
||||
borderColor: cssVarV2.layer.insideBorder.blackBorder,
|
||||
},
|
||||
'&[data-interactive="true"]:focus-visible': {
|
||||
outline: `2px solid ${cssVarV2.layer.insideBorder.primaryBorder}`,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const accountInfo = style({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Modal,
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
import { buildWorkspaceSettingsRedirectUri } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import {
|
||||
useQuery,
|
||||
type UseQueryConfig,
|
||||
@@ -30,6 +31,7 @@ import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -37,7 +39,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { WorkspaceService } from '../../../../modules/workspace';
|
||||
import { CollapsibleWrapper } from '../layout';
|
||||
import { CALENDAR_INTEGRATION_SCROLL_ANCHOR } from '../navigation-constants';
|
||||
import type { SettingState } from '../types';
|
||||
import * as styles from './integrations-panel.css';
|
||||
|
||||
type CalendarAccount = NonNullable<
|
||||
@@ -323,16 +328,22 @@ const CalDAVLinkDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationsPanel = () => {
|
||||
export const IntegrationsPanel = ({
|
||||
onChangeSettingState,
|
||||
}: {
|
||||
onChangeSettingState?: (settingState: SettingState) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const gqlService = useService(GraphQLService);
|
||||
const urlService = useService(UrlService);
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [openedExternalWindow, setOpenedExternalWindow] = useState(false);
|
||||
const [caldavDialogOpen, setCaldavDialogOpen] = useState(false);
|
||||
const canOpenCalendarSetting = workspaceService.workspace.flavour !== 'local';
|
||||
const makeConfig: <Query extends GraphQLQuery>(
|
||||
title: string
|
||||
) => UseQueryConfig<Query> = useCallback(
|
||||
@@ -394,6 +405,27 @@ export const IntegrationsPanel = () => {
|
||||
});
|
||||
}, [providers]);
|
||||
|
||||
const handleOpenCalendarSetting = useCallback(() => {
|
||||
if (!canOpenCalendarSetting) return;
|
||||
|
||||
onChangeSettingState?.({
|
||||
activeTab: 'workspace:integrations',
|
||||
scrollAnchor: CALENDAR_INTEGRATION_SCROLL_ANCHOR,
|
||||
});
|
||||
}, [canOpenCalendarSetting, onChangeSettingState]);
|
||||
|
||||
const handleAccountRowKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!canOpenCalendarSetting) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleOpenCalendarSetting();
|
||||
}
|
||||
},
|
||||
[canOpenCalendarSetting, handleOpenCalendarSetting]
|
||||
);
|
||||
|
||||
const handleLink = useCallback(
|
||||
async (provider: CalendarProviderType) => {
|
||||
if (provider === CalendarProviderType.CalDAV) {
|
||||
@@ -408,7 +440,12 @@ export const IntegrationsPanel = () => {
|
||||
variables: {
|
||||
input: {
|
||||
provider,
|
||||
redirectUri: window.location.href,
|
||||
redirectUri: canOpenCalendarSetting
|
||||
? buildWorkspaceSettingsRedirectUri(window.location.href, {
|
||||
tab: 'workspace:integrations',
|
||||
scrollAnchor: CALENDAR_INTEGRATION_SCROLL_ANCHOR,
|
||||
})
|
||||
: window.location.href,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -423,7 +460,7 @@ export const IntegrationsPanel = () => {
|
||||
setLinking(false);
|
||||
}
|
||||
},
|
||||
[gqlService, t, urlService]
|
||||
[canOpenCalendarSetting, gqlService, t, urlService]
|
||||
);
|
||||
|
||||
const handleUnlink = useCallback(
|
||||
@@ -535,7 +572,19 @@ export const IntegrationsPanel = () => {
|
||||
]();
|
||||
|
||||
return (
|
||||
<div key={account.id} className={styles.accountRow}>
|
||||
<div
|
||||
key={account.id}
|
||||
className={styles.accountRow}
|
||||
data-interactive={canOpenCalendarSetting}
|
||||
onClick={
|
||||
canOpenCalendarSetting
|
||||
? handleOpenCalendarSetting
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={handleAccountRowKeyDown}
|
||||
role={canOpenCalendarSetting ? 'button' : undefined}
|
||||
tabIndex={canOpenCalendarSetting ? 0 : undefined}
|
||||
>
|
||||
<div className={styles.accountInfo}>
|
||||
<div className={styles.accountIcon}>
|
||||
{meta?.icon ?? <LinkIcon />}
|
||||
@@ -562,7 +611,10 @@ export const IntegrationsPanel = () => {
|
||||
<Button
|
||||
variant="error"
|
||||
disabled={unlinkingAccountId === account.id}
|
||||
onClick={() => void handleUnlink(account.id)}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
handleUnlink(account.id).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.integration.calendar.account.unlink']()}
|
||||
</Button>
|
||||
|
||||
@@ -221,6 +221,7 @@ const SettingModalInner = ({
|
||||
) : isWorkspaceSetting(settingState.activeTab) ? (
|
||||
<WorkspaceSetting
|
||||
activeTab={settingState.activeTab}
|
||||
scrollAnchor={settingState.scrollAnchor}
|
||||
onCloseSetting={onCloseSetting}
|
||||
onChangeSettingState={setSettingState}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const CALENDAR_INTEGRATION_SCROLL_ANCHOR = 'integration-calendar';
|
||||
@@ -28,10 +28,12 @@ import { WorkspaceSettingStorage } from './storage';
|
||||
|
||||
export const WorkspaceSetting = ({
|
||||
activeTab,
|
||||
scrollAnchor,
|
||||
onCloseSetting,
|
||||
onChangeSettingState,
|
||||
}: {
|
||||
activeTab: SettingTab;
|
||||
scrollAnchor?: string;
|
||||
onCloseSetting: () => void;
|
||||
onChangeSettingState: (settingState: SettingState) => void;
|
||||
}) => {
|
||||
@@ -54,7 +56,7 @@ export const WorkspaceSetting = ({
|
||||
case 'workspace:license':
|
||||
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
|
||||
case 'workspace:integrations':
|
||||
return <IntegrationSetting />;
|
||||
return <IntegrationSetting scrollAnchor={scrollAnchor} />;
|
||||
case 'workspace:embedding':
|
||||
return <EmbeddingSettings />;
|
||||
default:
|
||||
|
||||
@@ -2,8 +2,15 @@ import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { CALENDAR_INTEGRATION_SCROLL_ANCHOR } from '../../navigation-constants';
|
||||
import { SubPageProvider, useSubPageIsland } from '../../sub-page';
|
||||
import {
|
||||
IntegrationCard,
|
||||
@@ -14,17 +21,34 @@ import { getAllowedIntegrationList } from './constants';
|
||||
import { type IntegrationItem } from './constants';
|
||||
import { list } from './index.css';
|
||||
|
||||
export const IntegrationSetting = () => {
|
||||
export const IntegrationSetting = ({
|
||||
scrollAnchor,
|
||||
}: {
|
||||
scrollAnchor?: string;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloudWorkspace = workspaceService.workspace.flavour !== 'local';
|
||||
console.log('isCloudWorkspace', isCloudWorkspace);
|
||||
|
||||
const integrationList = useMemo(
|
||||
() => getAllowedIntegrationList(isCloudWorkspace),
|
||||
[isCloudWorkspace]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAnchor !== CALENDAR_INTEGRATION_SCROLL_ANCHOR) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCalendarSetting = integrationList.some(
|
||||
item => item.id === 'calendar' && 'setting' in item
|
||||
);
|
||||
if (hasCalendarSetting) {
|
||||
setOpened('calendar');
|
||||
}
|
||||
}, [integrationList, scrollAnchor]);
|
||||
|
||||
const handleCardClick = useCallback((card: IntegrationItem) => {
|
||||
if ('setting' in card && card.setting) {
|
||||
setOpened(card.id);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import type * as ReactRouterDom from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const openAll = vi.hoisted(() => vi.fn());
|
||||
const openDialog = vi.hoisted(() => vi.fn());
|
||||
const searchParamsState = vi.hoisted(() => ({
|
||||
value: new URLSearchParams(),
|
||||
}));
|
||||
const WorkspaceDialogServiceToken = vi.hoisted(
|
||||
() => class WorkspaceDialogService {}
|
||||
);
|
||||
const WorkbenchServiceToken = vi.hoisted(() => class WorkbenchService {});
|
||||
|
||||
vi.mock('@affine/core/modules/dialogs', () => ({
|
||||
WorkspaceDialogService: WorkspaceDialogServiceToken,
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workbench', () => ({
|
||||
WorkbenchService: WorkbenchServiceToken,
|
||||
}));
|
||||
|
||||
vi.mock('@toeverything/infra', () => ({
|
||||
useService: (token: unknown) => {
|
||||
if (token === WorkbenchServiceToken) {
|
||||
return {
|
||||
workbench: {
|
||||
openAll,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (token === WorkspaceDialogServiceToken) {
|
||||
return {
|
||||
open: openDialog,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof ReactRouterDom>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => [searchParamsState.value],
|
||||
};
|
||||
});
|
||||
|
||||
import { Component } from './index';
|
||||
|
||||
describe('workspace settings page', () => {
|
||||
beforeEach(() => {
|
||||
openAll.mockReset();
|
||||
openDialog.mockReset();
|
||||
searchParamsState.value = new URLSearchParams();
|
||||
});
|
||||
|
||||
test('passes tab and scrollAnchor through to the settings dialog', () => {
|
||||
searchParamsState.value = new URLSearchParams({
|
||||
tab: 'workspace:integrations',
|
||||
scrollAnchor: 'integration-calendar',
|
||||
});
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(openAll).toHaveBeenCalled();
|
||||
expect(openDialog).toHaveBeenCalledWith('setting', {
|
||||
activeTab: 'workspace:integrations',
|
||||
scrollAnchor: 'integration-calendar',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export const Component = () => {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') ?? undefined;
|
||||
const scrollAnchor = searchParams.get('scrollAnchor') ?? undefined;
|
||||
|
||||
const isOpened = useRef(false);
|
||||
|
||||
@@ -23,7 +24,8 @@ export const Component = () => {
|
||||
workbench.openAll();
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: tab as SettingTab,
|
||||
scrollAnchor,
|
||||
});
|
||||
}, [tab, workbench, workspaceDialogService]);
|
||||
}, [scrollAnchor, tab, workbench, workspaceDialogService]);
|
||||
return null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user