From e3391c0577fe7e811297d6ce658e79e4d9b2140b Mon Sep 17 00:00:00 2001 From: chauhan_s Date: Mon, 6 Apr 2026 23:38:36 +0530 Subject: [PATCH] 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 ## 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. --------- Co-authored-by: DarkSky --- .../__tests__/use-navigate-helper.spec.ts | 59 ++++++++++++++ .../components/hooks/use-navigate-helper.ts | 67 +++++++++++++--- .../dialogs/setting/account-setting/index.tsx | 2 +- .../account-setting/integrations-panel.css.ts | 14 ++++ .../account-setting/integrations-panel.tsx | 62 +++++++++++++-- .../src/desktop/dialogs/setting/index.tsx | 1 + .../dialogs/setting/navigation-constants.ts | 1 + .../setting/workspace-setting/index.tsx | 4 +- .../workspace-setting/integration/index.tsx | 30 +++++++- .../pages/workspace/settings/index.spec.tsx | 77 +++++++++++++++++++ .../pages/workspace/settings/index.tsx | 4 +- 11 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/core/src/components/hooks/__tests__/use-navigate-helper.spec.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/navigation-constants.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/settings/index.spec.tsx diff --git a/packages/frontend/core/src/components/hooks/__tests__/use-navigate-helper.spec.ts b/packages/frontend/core/src/components/hooks/__tests__/use-navigate-helper.spec.ts new file mode 100644 index 000000000..061f72051 --- /dev/null +++ b/packages/frontend/core/src/components/hooks/__tests__/use-navigate-helper.spec.ts @@ -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'); + }); +}); diff --git a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts index c83304d27..4e42084e2 100644 --- a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts @@ -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] diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx index 923af4863..a039b32bd 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx @@ -242,7 +242,7 @@ export const AccountSetting = ({ {serverFeatures?.copilot && ( )} - + { +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( null ); const [openedExternalWindow, setOpenedExternalWindow] = useState(false); const [caldavDialogOpen, setCaldavDialogOpen] = useState(false); + const canOpenCalendarSetting = workspaceService.workspace.flavour !== 'local'; const makeConfig: ( title: string ) => UseQueryConfig = 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) => { + 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 ( -
+
{meta?.icon ?? } @@ -562,7 +611,10 @@ export const IntegrationsPanel = () => { diff --git a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx index 2c26374f4..825cbf6ce 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx @@ -221,6 +221,7 @@ const SettingModalInner = ({ ) : isWorkspaceSetting(settingState.activeTab) ? ( diff --git a/packages/frontend/core/src/desktop/dialogs/setting/navigation-constants.ts b/packages/frontend/core/src/desktop/dialogs/setting/navigation-constants.ts new file mode 100644 index 000000000..513fa9f6b --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/navigation-constants.ts @@ -0,0 +1 @@ +export const CALENDAR_INTEGRATION_SCROLL_ANCHOR = 'integration-calendar'; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx index 75856d708..51c0f50d5 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx @@ -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 ; case 'workspace:integrations': - return ; + return ; case 'workspace:embedding': return ; default: diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx index 7dc9a187c..bf978106f 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx @@ -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(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); diff --git a/packages/frontend/core/src/desktop/pages/workspace/settings/index.spec.tsx b/packages/frontend/core/src/desktop/pages/workspace/settings/index.spec.tsx new file mode 100644 index 000000000..da5197071 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/settings/index.spec.tsx @@ -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('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(); + + expect(openAll).toHaveBeenCalled(); + expect(openDialog).toHaveBeenCalledWith('setting', { + activeTab: 'workspace:integrations', + scrollAnchor: 'integration-calendar', + }); + }); +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/settings/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/settings/index.tsx index 3d04eedbe..9bb7df904 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/settings/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/settings/index.tsx @@ -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; };