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:
chauhan_s
2026-04-06 23:38:36 +05:30
committed by GitHub
parent 5806ad8a3a
commit e3391c0577
11 changed files with 301 additions and 20 deletions

View File

@@ -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');
});
});

View File

@@ -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]

View File

@@ -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']()}

View File

@@ -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({

View File

@@ -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>

View File

@@ -221,6 +221,7 @@ const SettingModalInner = ({
) : isWorkspaceSetting(settingState.activeTab) ? (
<WorkspaceSetting
activeTab={settingState.activeTab}
scrollAnchor={settingState.scrollAnchor}
onCloseSetting={onCloseSetting}
onChangeSettingState={setSettingState}
/>

View File

@@ -0,0 +1 @@
export const CALENDAR_INTEGRATION_SCROLL_ANCHOR = 'integration-calendar';

View File

@@ -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:

View File

@@ -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);

View File

@@ -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',
});
});
});

View File

@@ -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;
};