From cb550b7b21f2748b909d5d19431cef776ac0cf65 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Mon, 12 May 2025 12:54:51 +0000 Subject: [PATCH 01/20] refactor(editor): extract mobile extension builder (#12239) ## Summary by CodeRabbit - **New Features** - Introduced a new mobile view extension that activates mobile-specific UI features based on the runtime environment. - Automatically enables mobile keyboard toolbar and linked document menu features in mobile contexts. - **Improvements** - Simplified code and paragraph block configurations on mobile, including disabling line numbers and adjusting placeholders. - Enhanced configuration chaining to include mobile-specific settings by default. - Improved extension registration flow with method chaining support. - **Refactor** - Removed deprecated mobile patch classes and configurations, consolidating mobile logic into dedicated extensions. - Streamlined mobile-related code for better maintainability and performance. --- blocksuite/affine/blocks/code/src/view.ts | 9 +++- .../affine/ext-loader/src/base-provider.ts | 2 +- blocksuite/affine/ext-loader/src/manager.ts | 9 ++-- .../block-suite-editor/lit-adaper.tsx | 3 +- .../src/blocksuite/extensions/mobile/index.ts | 33 ++++++++++++ .../keyboard-toolbar-extension.ts} | 39 ++------------- .../mobile/mobile-feature-flag-control.ts | 14 ++++++ .../src/blocksuite/manager/common-view.ts | 21 ++++---- .../src/blocksuite/manager/editor-view.tsx | 18 ------- .../src/blocksuite/manager/migrating-store.ts | 2 + .../src/blocksuite/manager/migrating-view.ts | 50 +++++++++++++++++-- 11 files changed, 128 insertions(+), 72 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/extensions/mobile/index.ts rename packages/frontend/core/src/blocksuite/extensions/{mobile-config.ts => mobile/keyboard-toolbar-extension.ts} (66%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/mobile/mobile-feature-flag-control.ts diff --git a/blocksuite/affine/blocks/code/src/view.ts b/blocksuite/affine/blocks/code/src/view.ts index d5aa50c5b1..eab094fd3b 100644 --- a/blocksuite/affine/blocks/code/src/view.ts +++ b/blocksuite/affine/blocks/code/src/view.ts @@ -11,6 +11,7 @@ import { import { literal, unsafeStatic } from 'lit/static-html.js'; import { getCodeClipboardExtensions } from './clipboard/index.js'; +import { CodeBlockConfigExtension } from './code-block-config'; import { CodeBlockInlineManagerExtension, CodeBlockUnitSpecExtension, @@ -21,7 +22,7 @@ import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js'; import { codeSlashMenuConfig } from './configs/slash-menu.js'; import { effects } from './effects.js'; -export const codeToolbarWidget = WidgetViewExtension( +const codeToolbarWidget = WidgetViewExtension( 'affine:code', AFFINE_CODE_TOOLBAR_WIDGET, literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}` @@ -51,6 +52,12 @@ export class CodeBlockViewExtension extends ViewExtensionProvider { ]); if (!this.isMobile(context.scope)) { context.register(codeToolbarWidget); + } else { + context.register( + CodeBlockConfigExtension({ + showLineNumbers: false, + }) + ); } } } diff --git a/blocksuite/affine/ext-loader/src/base-provider.ts b/blocksuite/affine/ext-loader/src/base-provider.ts index 6c54cbd9d4..adcc9bd5b5 100644 --- a/blocksuite/affine/ext-loader/src/base-provider.ts +++ b/blocksuite/affine/ext-loader/src/base-provider.ts @@ -17,7 +17,7 @@ export type Context = { /** The scope this context is associated with */ scope: Scope; /** Function to register one or more extensions */ - register(extensions: ExtensionType[] | ExtensionType): void; + register(extensions: ExtensionType[] | ExtensionType): Context; }; /** diff --git a/blocksuite/affine/ext-loader/src/manager.ts b/blocksuite/affine/ext-loader/src/manager.ts index da7857e039..f6b55ef2d4 100644 --- a/blocksuite/affine/ext-loader/src/manager.ts +++ b/blocksuite/affine/ext-loader/src/manager.ts @@ -75,11 +75,14 @@ export class ExtensionManager { /** @internal */ private readonly _getContextByScope = (scope: Scope): Context => { - return { + const context: Context = { scope, - register: (extensions: ExtensionType[] | ExtensionType) => - this._registerToScope(scope, extensions), + register: (extensions: ExtensionType[] | ExtensionType) => { + this._registerToScope(scope, extensions); + return context; + }, }; + return context; }; /** diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index cdaf3f432c..835b355225 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -98,7 +98,8 @@ const usePatchSpecs = (mode: DocMode) => { }) .database(framework) .linkedDoc(framework) - .paragraph(enableAI).value; + .paragraph(enableAI) + .mobile(framework).value; if (BUILD_CONFIG.isMobileEdition) { if (mode === 'page') { diff --git a/packages/frontend/core/src/blocksuite/extensions/mobile/index.ts b/packages/frontend/core/src/blocksuite/extensions/mobile/index.ts new file mode 100644 index 0000000000..cbec7ed4ef --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/mobile/index.ts @@ -0,0 +1,33 @@ +import { KeyboardToolbarExtension } from '@affine/core/blocksuite/extensions/mobile/keyboard-toolbar-extension'; +import { MobileFeatureFlagControl } from '@affine/core/blocksuite/extensions/mobile/mobile-feature-flag-control'; +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine/ext-loader'; +import { FrameworkProvider } from '@toeverything/infra'; +import { z } from 'zod'; + +const optionsSchema = z.object({ + framework: z.instanceof(FrameworkProvider).optional(), +}); + +type MobileViewOptions = z.infer; + +export class MobileViewExtension extends ViewExtensionProvider { + override name = 'mobile-view-extension'; + + override schema = optionsSchema; + + override setup(context: ViewExtensionContext, options?: MobileViewOptions) { + super.setup(context, options); + const isMobile = BUILD_CONFIG.isMobileEdition; + if (!isMobile) return; + + const framework = options?.framework; + if (framework) { + context.register(KeyboardToolbarExtension(framework)); + } + + context.register(MobileFeatureFlagControl); + } +} diff --git a/packages/frontend/core/src/blocksuite/extensions/mobile-config.ts b/packages/frontend/core/src/blocksuite/extensions/mobile/keyboard-toolbar-extension.ts similarity index 66% rename from packages/frontend/core/src/blocksuite/extensions/mobile-config.ts rename to packages/frontend/core/src/blocksuite/extensions/mobile/keyboard-toolbar-extension.ts index 2e85f890b9..ff0d567a61 100644 --- a/packages/frontend/core/src/blocksuite/extensions/mobile-config.ts +++ b/packages/frontend/core/src/blocksuite/extensions/mobile/keyboard-toolbar-extension.ts @@ -1,50 +1,15 @@ import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; -import { CodeBlockConfigExtension } from '@blocksuite/affine/blocks/code'; -import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph'; import type { Container } from '@blocksuite/affine/global/di'; import { DisposableGroup } from '@blocksuite/affine/global/disposable'; import { - FeatureFlagService, VirtualKeyboardProvider as BSVirtualKeyboardProvider, type VirtualKeyboardProviderWithAction, } from '@blocksuite/affine/shared/services'; -import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std'; +import { LifeCycleWatcher } from '@blocksuite/affine/std'; import type { ExtensionType } from '@blocksuite/affine/store'; import { batch, signal } from '@preact/signals-core'; import type { FrameworkProvider } from '@toeverything/infra'; -export class MobileSpecsPatches extends LifeCycleWatcher { - static override key = 'mobile-patches'; - - constructor(std: BlockStdScope) { - super(std); - const featureFlagService = std.get(FeatureFlagService); - - featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true); - featureFlagService.setFlag('enable_mobile_linked_doc_menu', true); - } -} - -export const mobileParagraphConfig = ParagraphBlockConfigExtension({ - getPlaceholder: model => { - const placeholders = { - text: '', - h1: 'Heading 1', - h2: 'Heading 2', - h3: 'Heading 3', - h4: 'Heading 4', - h5: 'Heading 5', - h6: 'Heading 6', - quote: '', - }; - return placeholders[model.props.type]; - }, -}); - -export const mobileCodeConfig = CodeBlockConfigExtension({ - showLineNumbers: false, -}); - export function KeyboardToolbarExtension( framework: FrameworkProvider ): ExtensionType { @@ -89,6 +54,7 @@ export function KeyboardToolbarExtension( if ('show' in affineVirtualKeyboardProvider) { const providerWithAction = affineVirtualKeyboardProvider; + class BSVirtualKeyboardServiceWithShowAndHide extends BSVirtualKeyboardService implements VirtualKeyboardProviderWithAction @@ -96,6 +62,7 @@ export function KeyboardToolbarExtension( show() { providerWithAction.show(); } + hide() { providerWithAction.hide(); } diff --git a/packages/frontend/core/src/blocksuite/extensions/mobile/mobile-feature-flag-control.ts b/packages/frontend/core/src/blocksuite/extensions/mobile/mobile-feature-flag-control.ts new file mode 100644 index 0000000000..62bade7156 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/mobile/mobile-feature-flag-control.ts @@ -0,0 +1,14 @@ +import { FeatureFlagService } from '@blocksuite/affine/shared/services'; +import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std'; + +export class MobileFeatureFlagControl extends LifeCycleWatcher { + static override key = 'mobile-patches'; + + constructor(std: BlockStdScope) { + super(std); + const featureFlagService = std.get(FeatureFlagService); + + featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true); + featureFlagService.setFlag('enable_mobile_linked_doc_menu', true); + } +} diff --git a/packages/frontend/core/src/blocksuite/manager/common-view.ts b/packages/frontend/core/src/blocksuite/manager/common-view.ts index 10036a8630..72c8f189c6 100644 --- a/packages/frontend/core/src/blocksuite/manager/common-view.ts +++ b/packages/frontend/core/src/blocksuite/manager/common-view.ts @@ -11,6 +11,7 @@ import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool'; import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel'; import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot'; import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta'; +import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard'; import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit'; import { getFontConfigExtension } from '@affine/core/blocksuite/extensions/font-config'; import { patchPeekViewService } from '@affine/core/blocksuite/extensions/peek-view-service'; @@ -41,15 +42,17 @@ export class AffineCommonViewExtension extends ViewExtensionProvider< context: ViewExtensionContext, framework: FrameworkProvider ) { - context.register(AIChatBlockSpec); - context.register(AITranscriptionBlockSpec); - context.register([ - AICodeBlockWatcher, - ToolbarModuleExtension({ - id: BlockFlavourIdentifier('custom:affine:image'), - config: imageToolbarAIEntryConfig(), - }), - ]); + context + .register(AIChatBlockSpec) + .register(AITranscriptionBlockSpec) + .register(EdgelessClipboardAIChatConfig) + .register(AICodeBlockWatcher) + .register( + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:image'), + config: imageToolbarAIEntryConfig(), + }) + ); if (context.scope === 'edgeless' || context.scope === 'page') { context.register([ aiPanelWidget, diff --git a/packages/frontend/core/src/blocksuite/manager/editor-view.tsx b/packages/frontend/core/src/blocksuite/manager/editor-view.tsx index 3dfabf579f..5061fba87c 100644 --- a/packages/frontend/core/src/blocksuite/manager/editor-view.tsx +++ b/packages/frontend/core/src/blocksuite/manager/editor-view.tsx @@ -3,7 +3,6 @@ import { patchForAudioEmbedView } from '@affine/core/blocksuite/extensions/audio import { patchDatabaseBlockConfigService } from '@affine/core/blocksuite/extensions/database-block-config-service'; import { patchDocModeService } from '@affine/core/blocksuite/extensions/doc-mode-service'; import { patchDocUrlExtensions } from '@affine/core/blocksuite/extensions/doc-url'; -import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard'; import { patchForClipboardInElectron } from '@affine/core/blocksuite/extensions/electron-clipboard'; import { patchNotificationService } from '@affine/core/blocksuite/extensions/notification-service'; import { patchOpenDocExtension } from '@affine/core/blocksuite/extensions/open-doc'; @@ -29,13 +28,6 @@ import { FrameworkProvider } from '@toeverything/infra'; import type { TemplateResult } from 'lit'; import { z } from 'zod'; -import { - KeyboardToolbarExtension, - mobileCodeConfig, - mobileParagraphConfig, - MobileSpecsPatches, -} from '../extensions/mobile-config'; - const optionsSchema = z.object({ // services framework: z.instanceof(FrameworkProvider), @@ -106,7 +98,6 @@ export class AffineEditorViewExtension extends ViewExtensionProvider Configure; + featureFlag: (featureFlagService?: FeatureFlagService) => Configure; + value: StoreExtensionManager; } diff --git a/packages/frontend/core/src/blocksuite/manager/migrating-view.ts b/packages/frontend/core/src/blocksuite/manager/migrating-view.ts index bbaee5e637..6b5a9b793a 100644 --- a/packages/frontend/core/src/blocksuite/manager/migrating-view.ts +++ b/packages/frontend/core/src/blocksuite/manager/migrating-view.ts @@ -7,6 +7,7 @@ import { import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensions/editor-config'; import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database'; import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked'; +import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile'; import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf'; import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme'; import { TurboRendererViewExtension } from '@affine/core/blocksuite/extensions/turbo-renderer'; @@ -24,6 +25,25 @@ import type { FrameworkProvider } from '@toeverything/infra'; import { CodeBlockPreviewViewExtension } from './code-block-preview'; +type Configure = { + init: () => Configure; + + common: (framework?: FrameworkProvider, enableAI?: boolean) => Configure; + editorView: (options?: AffineEditorViewOptions) => Configure; + theme: (framework?: FrameworkProvider) => Configure; + editorConfig: (framework?: FrameworkProvider) => Configure; + edgelessBlockHeader: (options?: EdgelessBlockHeaderViewOptions) => Configure; + database: (framework?: FrameworkProvider) => Configure; + linkedDoc: (framework?: FrameworkProvider) => Configure; + paragraph: (enableAI?: boolean) => Configure; + cloud: (framework?: FrameworkProvider, enableCloud?: boolean) => Configure; + turboRenderer: (enableTurboRenderer?: boolean) => Configure; + pdf: (enablePDFEmbedPreview?: boolean, reactToLit?: ReactToLit) => Configure; + mobile: (framework?: FrameworkProvider) => Configure; + + value: ViewExtensionManager; +}; + class ViewProvider { static instance: ViewProvider | null = null; static getInstance() { @@ -48,6 +68,7 @@ class ViewProvider { TurboRendererViewExtension, CloudViewExtension, PdfViewExtension, + MobileViewExtension, ]); } @@ -55,7 +76,7 @@ class ViewProvider { return this._manager; } - get config() { + get config(): Configure { return { init: this._initDefaultConfig, common: this._configureCommon, @@ -69,6 +90,7 @@ class ViewProvider { cloud: this._configureCloud, turboRenderer: this._configureTurboRenderer, pdf: this._configurePdf, + mobile: this._configureMobile, value: this._manager, }; } @@ -85,7 +107,8 @@ class ViewProvider { .paragraph() .cloud() .turboRenderer() - .pdf(); + .pdf() + .mobile(); return this.config; }; @@ -146,7 +169,23 @@ class ViewProvider { }; private readonly _configureParagraph = (enableAI?: boolean) => { - if (enableAI) { + if (BUILD_CONFIG.isMobileEdition) { + this._manager.configure(ParagraphViewExtension, { + getPlaceholder: model => { + const placeholders = { + text: '', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + quote: '', + }; + return placeholders[model.props.type] ?? ''; + }, + }); + } else if (enableAI) { this._manager.configure(ParagraphViewExtension, { getPlaceholder: model => { const placeholders = { @@ -193,6 +232,11 @@ class ViewProvider { }); return this.config; }; + + private readonly _configureMobile = (framework?: FrameworkProvider) => { + this._manager.configure(MobileViewExtension, { framework }); + return this.config; + }; } export function getViewManager() { From 1426a38c9f579e6e8a44f6ab23220a217b965d29 Mon Sep 17 00:00:00 2001 From: Mirone Date: Tue, 13 May 2025 09:33:32 +0800 Subject: [PATCH 02/20] refactor(editor): extract ai extension builder (#12240) --- .../bi-directional-link-panel.tsx | 3 +- .../block-suite-editor/lit-adaper.tsx | 6 +- .../extensions/{ => ai}/edgeless-clipboard.ts | 0 .../src/blocksuite/extensions/ai/index.ts | 79 +++++++++++++++++++ .../{ => electron}/electron-clipboard.ts | 0 .../blocksuite/extensions/electron/index.ts | 33 ++++++++ .../src/blocksuite/manager/common-view.ts | 63 +-------------- .../src/blocksuite/manager/editor-view.tsx | 32 ++++---- .../src/blocksuite/manager/migrating-view.ts | 31 ++++++-- 9 files changed, 158 insertions(+), 89 deletions(-) rename packages/frontend/core/src/blocksuite/extensions/{ => ai}/edgeless-clipboard.ts (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/ai/index.ts rename packages/frontend/core/src/blocksuite/extensions/{ => electron}/electron-clipboard.ts (100%) create mode 100644 packages/frontend/core/src/blocksuite/extensions/electron/index.ts diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx index c30c4fdab3..1583649f87 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx @@ -165,7 +165,8 @@ const usePreviewExtensions = () => { const extensions = useMemo(() => { const manager = getViewManager() .config.init() - .common(framework, enableAI) + .common(framework) + .ai(enableAI, framework) .theme(framework) .database(framework) .linkedDoc(framework) diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index 835b355225..cb97591521 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -80,7 +80,8 @@ const usePatchSpecs = (mode: DocMode) => { const patchedSpecs = useMemo(() => { const manager = getViewManager() .config.init() - .common(framework, enableAI) + .common(framework) + .ai(enableAI, framework) .theme(framework) .editorConfig(framework) .editorView({ @@ -99,7 +100,8 @@ const usePatchSpecs = (mode: DocMode) => { .database(framework) .linkedDoc(framework) .paragraph(enableAI) - .mobile(framework).value; + .mobile(framework) + .electron(framework).value; if (BUILD_CONFIG.isMobileEdition) { if (mode === 'page') { diff --git a/packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts b/packages/frontend/core/src/blocksuite/extensions/ai/edgeless-clipboard.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/extensions/edgeless-clipboard.ts rename to packages/frontend/core/src/blocksuite/extensions/ai/edgeless-clipboard.ts diff --git a/packages/frontend/core/src/blocksuite/extensions/ai/index.ts b/packages/frontend/core/src/blocksuite/extensions/ai/index.ts new file mode 100644 index 0000000000..8f82676eed --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/ai/index.ts @@ -0,0 +1,79 @@ +import { toolbarAIEntryConfig } from '@affine/core/blocksuite/ai'; +import { AIChatBlockSpec } from '@affine/core/blocksuite/ai/blocks'; +import { AITranscriptionBlockSpec } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/ai-transcription-block'; +import { edgelessToolbarAIEntryConfig } from '@affine/core/blocksuite/ai/entries/edgeless'; +import { imageToolbarAIEntryConfig } from '@affine/core/blocksuite/ai/entries/image-toolbar/setup-image-toolbar'; +import { AICodeBlockWatcher } from '@affine/core/blocksuite/ai/extensions/ai-code'; +import { getAIEdgelessRootWatcher } from '@affine/core/blocksuite/ai/extensions/ai-edgeless-root'; +import { getAIPageRootWatcher } from '@affine/core/blocksuite/ai/extensions/ai-page-root'; +import { AiSlashMenuConfigExtension } from '@affine/core/blocksuite/ai/extensions/ai-slash-menu'; +import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool'; +import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel'; +import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot'; +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine/ext-loader'; +import { ToolbarModuleExtension } from '@blocksuite/affine/shared/services'; +import { BlockFlavourIdentifier } from '@blocksuite/affine/std'; +import { FrameworkProvider } from '@toeverything/infra'; +import { z } from 'zod'; + +import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard'; + +const optionsSchema = z.object({ + enable: z.boolean().optional(), + framework: z.instanceof(FrameworkProvider).optional(), +}); + +type AIViewOptions = z.infer; + +export class AIViewExtension extends ViewExtensionProvider { + override name = 'affine-ai-view-extension'; + + override schema = optionsSchema; + + override setup(context: ViewExtensionContext, options?: AIViewOptions) { + super.setup(context, options); + if (!options?.enable) return; + const framework = options.framework; + if (!framework) return; + + context + .register(AIChatBlockSpec) + .register(AITranscriptionBlockSpec) + .register(EdgelessClipboardAIChatConfig) + .register(AICodeBlockWatcher) + .register( + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:image'), + config: imageToolbarAIEntryConfig(), + }) + ); + if (context.scope === 'edgeless' || context.scope === 'page') { + context.register([ + aiPanelWidget, + AiSlashMenuConfigExtension(), + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:note'), + config: toolbarAIEntryConfig(), + }), + ]); + } + if (context.scope === 'edgeless') { + context.register([ + CopilotTool, + edgelessCopilotWidget, + getAIEdgelessRootWatcher(framework), + // In note + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:surface:*'), + config: edgelessToolbarAIEntryConfig(), + }), + ]); + } + if (context.scope === 'page') { + context.register(getAIPageRootWatcher(framework)); + } + } +} diff --git a/packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts b/packages/frontend/core/src/blocksuite/extensions/electron/electron-clipboard.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/extensions/electron-clipboard.ts rename to packages/frontend/core/src/blocksuite/extensions/electron/electron-clipboard.ts diff --git a/packages/frontend/core/src/blocksuite/extensions/electron/index.ts b/packages/frontend/core/src/blocksuite/extensions/electron/index.ts new file mode 100644 index 0000000000..933c37e298 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/electron/index.ts @@ -0,0 +1,33 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine/ext-loader'; +import { FrameworkProvider } from '@toeverything/infra'; +import { z } from 'zod'; + +import { patchForClipboardInElectron } from './electron-clipboard'; + +const optionsSchema = z.object({ + framework: z.instanceof(FrameworkProvider).optional(), +}); + +type ElectronViewExtensionOptions = z.infer; + +export class ElectronViewExtension extends ViewExtensionProvider { + override name = 'electron-view-extensions'; + + override schema = optionsSchema; + + override setup( + context: ViewExtensionContext, + options?: ElectronViewExtensionOptions + ) { + super.setup(context, options); + if (!BUILD_CONFIG.isElectron) return; + + const framework = options?.framework; + if (!framework) return; + + context.register(patchForClipboardInElectron(framework)); + } +} diff --git a/packages/frontend/core/src/blocksuite/manager/common-view.ts b/packages/frontend/core/src/blocksuite/manager/common-view.ts index 72c8f189c6..9fad13c529 100644 --- a/packages/frontend/core/src/blocksuite/manager/common-view.ts +++ b/packages/frontend/core/src/blocksuite/manager/common-view.ts @@ -1,17 +1,4 @@ -import { toolbarAIEntryConfig } from '@affine/core/blocksuite/ai'; -import { AIChatBlockSpec } from '@affine/core/blocksuite/ai/blocks'; -import { AITranscriptionBlockSpec } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/ai-transcription-block'; -import { edgelessToolbarAIEntryConfig } from '@affine/core/blocksuite/ai/entries/edgeless'; -import { imageToolbarAIEntryConfig } from '@affine/core/blocksuite/ai/entries/image-toolbar/setup-image-toolbar'; -import { AICodeBlockWatcher } from '@affine/core/blocksuite/ai/extensions/ai-code'; -import { getAIEdgelessRootWatcher } from '@affine/core/blocksuite/ai/extensions/ai-edgeless-root'; -import { getAIPageRootWatcher } from '@affine/core/blocksuite/ai/extensions/ai-page-root'; -import { AiSlashMenuConfigExtension } from '@affine/core/blocksuite/ai/extensions/ai-slash-menu'; -import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool'; -import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel'; -import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot'; import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta'; -import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard'; import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit'; import { getFontConfigExtension } from '@affine/core/blocksuite/extensions/font-config'; import { patchPeekViewService } from '@affine/core/blocksuite/extensions/peek-view-service'; @@ -21,13 +8,10 @@ import { type ViewExtensionContext, ViewExtensionProvider, } from '@blocksuite/affine/ext-loader'; -import { ToolbarModuleExtension } from '@blocksuite/affine/shared/services'; -import { BlockFlavourIdentifier } from '@blocksuite/affine/std'; import { FrameworkProvider } from '@toeverything/infra'; import { z } from 'zod'; const optionsSchema = z.object({ - enableAI: z.boolean().optional(), framework: z.instanceof(FrameworkProvider).optional(), }); @@ -38,54 +22,12 @@ export class AffineCommonViewExtension extends ViewExtensionProvider< override schema = optionsSchema; - private _setupAI( - context: ViewExtensionContext, - framework: FrameworkProvider - ) { - context - .register(AIChatBlockSpec) - .register(AITranscriptionBlockSpec) - .register(EdgelessClipboardAIChatConfig) - .register(AICodeBlockWatcher) - .register( - ToolbarModuleExtension({ - id: BlockFlavourIdentifier('custom:affine:image'), - config: imageToolbarAIEntryConfig(), - }) - ); - if (context.scope === 'edgeless' || context.scope === 'page') { - context.register([ - aiPanelWidget, - AiSlashMenuConfigExtension(), - ToolbarModuleExtension({ - id: BlockFlavourIdentifier('custom:affine:note'), - config: toolbarAIEntryConfig(), - }), - ]); - } - if (context.scope === 'edgeless') { - context.register([ - CopilotTool, - edgelessCopilotWidget, - getAIEdgelessRootWatcher(framework), - // In note - ToolbarModuleExtension({ - id: BlockFlavourIdentifier('custom:affine:surface:*'), - config: edgelessToolbarAIEntryConfig(), - }), - ]); - } - if (context.scope === 'page') { - context.register(getAIPageRootWatcher(framework)); - } - } - override setup( context: ViewExtensionContext, options?: z.infer ) { super.setup(context, options); - const { framework, enableAI } = options || {}; + const { framework } = options || {}; if (framework) { context.register(patchPeekViewService(framework.get(PeekViewService))); context.register([ @@ -96,9 +38,6 @@ export class AffineCommonViewExtension extends ViewExtensionProvider< if (context.scope === 'edgeless' || context.scope === 'page') { context.register(patchFileSizeLimitExtension(framework)); } - if (enableAI) { - this._setupAI(context, framework); - } } } } diff --git a/packages/frontend/core/src/blocksuite/manager/editor-view.tsx b/packages/frontend/core/src/blocksuite/manager/editor-view.tsx index 5061fba87c..8be7fdc768 100644 --- a/packages/frontend/core/src/blocksuite/manager/editor-view.tsx +++ b/packages/frontend/core/src/blocksuite/manager/editor-view.tsx @@ -3,7 +3,6 @@ import { patchForAudioEmbedView } from '@affine/core/blocksuite/extensions/audio import { patchDatabaseBlockConfigService } from '@affine/core/blocksuite/extensions/database-block-config-service'; import { patchDocModeService } from '@affine/core/blocksuite/extensions/doc-mode-service'; import { patchDocUrlExtensions } from '@affine/core/blocksuite/extensions/doc-url'; -import { patchForClipboardInElectron } from '@affine/core/blocksuite/extensions/electron-clipboard'; import { patchNotificationService } from '@affine/core/blocksuite/extensions/notification-service'; import { patchOpenDocExtension } from '@affine/core/blocksuite/extensions/open-doc'; import { patchQuickSearchService } from '@affine/core/blocksuite/extensions/quick-search-service'; @@ -98,7 +97,6 @@ export class AffineEditorViewExtension extends ViewExtensionProvider Configure; pdf: (enablePDFEmbedPreview?: boolean, reactToLit?: ReactToLit) => Configure; mobile: (framework?: FrameworkProvider) => Configure; + ai: (enable?: boolean, framework?: FrameworkProvider) => Configure; + electron: (framework?: FrameworkProvider) => Configure; value: ViewExtensionManager; }; @@ -69,6 +73,8 @@ class ViewProvider { CloudViewExtension, PdfViewExtension, MobileViewExtension, + AIViewExtension, + ElectronViewExtension, ]); } @@ -91,6 +97,8 @@ class ViewProvider { turboRenderer: this._configureTurboRenderer, pdf: this._configurePdf, mobile: this._configureMobile, + ai: this._configureAI, + electron: this._configureElectron, value: this._manager, }; } @@ -108,18 +116,16 @@ class ViewProvider { .cloud() .turboRenderer() .pdf() - .mobile(); + .mobile() + .ai() + .electron(); return this.config; }; - private readonly _configureCommon = ( - framework?: FrameworkProvider, - enableAI?: boolean - ) => { + private readonly _configureCommon = (framework?: FrameworkProvider) => { this._manager.configure(AffineCommonViewExtension, { framework, - enableAI, }); return this.config; }; @@ -237,6 +243,19 @@ class ViewProvider { this._manager.configure(MobileViewExtension, { framework }); return this.config; }; + + private readonly _configureAI = ( + enable?: boolean, + framework?: FrameworkProvider + ) => { + this._manager.configure(AIViewExtension, { framework, enable }); + return this.config; + }; + + private readonly _configureElectron = (framework?: FrameworkProvider) => { + this._manager.configure(ElectronViewExtension, { framework }); + return this.config; + }; } export function getViewManager() { From 662614de0d6643a7099a15819366bf6357b3569c Mon Sep 17 00:00:00 2001 From: JimmFly Date: Tue, 13 May 2025 02:43:01 +0000 Subject: [PATCH 03/20] feat(admin): create user with password (#12112) close AF-2494 ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added support for importing users with optional passwords via CSV, including password validation and error handling. - Enhanced user creation form to allow password input and validation according to server-configured requirements. - User table now displays a password column and provides granular error highlighting for email and password fields. - Introduced detailed CSV format guidance and improved import dialog with dynamic content and footer controls. - **Improvements** - Updated import dialog workflow for clearer CSV formatting guidance and improved user feedback during import. - Refined dropdown menu actions and improved UI clarity for user management actions. - **Bug Fixes** - Corrected menu action handlers for "Edit" and "Reset Password" in user actions dropdown. - **Chores** - Refactored import dialog code into modular components for maintainability and clarity. --- .../components/data-table-row-actions.tsx | 25 +- .../components/data-table-toolbar.tsx | 2 +- .../components/import-users-dialog.tsx | 314 ------------------ .../import-users/csv-format-guidance.tsx | 51 +++ .../{ => import-users}/file-upload-area.tsx | 0 .../import-users/import-content.tsx | 73 ++++ .../components/import-users/import-footer.tsx | 168 ++++++++++ .../components/import-users/index.tsx | 109 ++++++ .../import-users/use-import-users-state.ts | 193 +++++++++++ .../components/use-user-management.ts | 3 +- .../modules/accounts/components/user-form.tsx | 63 +++- .../accounts/components/user-table.tsx | 23 +- .../admin/src/modules/accounts/schema.ts | 1 + .../src/modules/accounts/utils/csv-utils.ts | 75 ++++- 14 files changed, 764 insertions(+), 336 deletions(-) delete mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx create mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx rename packages/frontend/admin/src/modules/accounts/components/{ => import-users}/file-upload-area.tsx (100%) create mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx create mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx create mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx create mode 100644 packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx index a71c13bd25..e6ae064e86 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx @@ -170,24 +170,27 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) { - - Reset Password - - Edit + + Edit + + + + {user.hasPassword ? 'Reset Password' : 'Setup Account'} {user.disabled && ( - Enable Email + + Enable Email )} @@ -196,14 +199,16 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) { className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500" onSelect={openDisableDialog} > - Disable & Delete data + + Disable & Delete data )} - Delete + + Delete diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx index 82a9b2d0aa..fb49b6e6c2 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx @@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context'; import type { UserType } from '../schema'; import { DiscardChanges } from './discard-changes'; import { ExportUsersDialog } from './export-users-dialog'; -import { ImportUsersDialog } from './import-users-dialog'; +import { ImportUsersDialog } from './import-users'; import { CreateUserForm } from './user-form'; interface DataTableToolbarProps { diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx deleted file mode 100644 index 97e6c818cb..0000000000 --- a/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { Button } from '@affine/admin/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@affine/admin/components/ui/dialog'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; - -import { - downloadCsvTemplate, - exportImportResults, - getValidUsersToImport, - ImportStatus, - type ParsedUser, - processCSVFile, -} from '../utils/csv-utils'; -import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area'; -import { - useImportUsers, - type UserImportReturnType, -} from './use-user-management'; -import { UserTable } from './user-table'; - -interface ImportUsersDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ImportUsersDialog({ - open, - onOpenChange, -}: ImportUsersDialogProps) { - const [isImporting, setIsImporting] = useState(false); - const [parsedUsers, setParsedUsers] = useState([]); - const [isPreviewMode, setIsPreviewMode] = useState(false); - const [isFormatError, setIsFormatError] = useState(false); - const importUsers = useImportUsers(); - const fileUploadRef = useRef(null); - - const handleUpload = useCallback( - () => fileUploadRef.current?.triggerFileUpload(), - [] - ); - - // Reset all states when dialog is closed - useEffect(() => { - if (open) { - setIsPreviewMode(false); - setParsedUsers([]); - setIsImporting(false); - setIsFormatError(false); - } - }, [open]); - - const importUsersCallback = useCallback( - (result: UserImportReturnType) => { - const successfulUsers = result.filter( - (user): user is Extract => - user.__typename === 'UserType' - ); - - const failedUsers = result.filter( - ( - user - ): user is Extract< - typeof user, - { __typename: 'UserImportFailedType' } - > => user.__typename === 'UserImportFailedType' - ); - - const successCount = successfulUsers.length; - const failedCount = parsedUsers.length - successCount; - - if (failedCount > 0) { - toast.info( - `Successfully imported ${successCount} users, ${failedCount} failed` - ); - } else { - toast.success(`Successfully imported ${successCount} users`); - } - - const successfulUserEmails = new Set( - successfulUsers.map(user => user.email) - ); - - const failedUserErrorMap = new Map( - failedUsers.map(user => [user.email, user.error]) - ); - - setParsedUsers(prev => { - return prev.map(user => { - if (successfulUserEmails.has(user.email)) { - return { - ...user, - importStatus: ImportStatus.Success, - }; - } - - const errorMessage = failedUserErrorMap.get(user.email) || user.error; - return { - ...user, - importStatus: ImportStatus.Failed, - importError: errorMessage, - }; - }); - }); - - setIsImporting(false); - }, - [parsedUsers.length, setIsImporting] - ); - - const handleFileSelected = useCallback(async (file: File) => { - setIsImporting(true); - try { - await processCSVFile( - file, - validatedUsers => { - setParsedUsers(validatedUsers); - setIsPreviewMode(true); - setIsImporting(false); - }, - () => { - setIsImporting(false); - setIsFormatError(true); - } - ); - } catch (error) { - console.error('Failed to process file', error); - setIsImporting(false); - setIsFormatError(true); - } - }, []); - - const confirmImport = useAsyncCallback(async () => { - setIsImporting(true); - try { - const validUsersToImport = getValidUsersToImport(parsedUsers); - - setParsedUsers(prev => - prev.map(user => - user.valid ? { ...user, importStatus: ImportStatus.Processing } : user - ) - ); - - await importUsers({ users: validUsersToImport }, importUsersCallback); - // Note: setIsImporting(false) is now handled in importUsersCallback - } catch (error) { - console.error('Failed to import users', error); - toast.error('Failed to import users'); - setIsImporting(false); - } - }, [importUsers, importUsersCallback, parsedUsers]); - - const cancelImport = useCallback(() => { - setIsPreviewMode(false); - setParsedUsers([]); - }, []); - - const resetFormatError = useCallback(() => { - setIsFormatError(false); - }, []); - - // Handle closing the dialog after import is complete - const handleDone = useCallback(() => { - // Reset all states and close the dialog - setIsPreviewMode(false); - setParsedUsers([]); - setIsImporting(false); - setIsFormatError(false); - onOpenChange(false); - }, [onOpenChange]); - - // Export failed imports to CSV - const exportResult = useCallback(() => { - exportImportResults(parsedUsers); - }, [parsedUsers]); - - const isImported = useMemo(() => { - return parsedUsers.some( - user => user.importStatus && user.importStatus !== ImportStatus.Processing - ); - }, [parsedUsers]); - - const handleConfirm = useCallback(() => { - if (isImported) { - exportResult(); - handleDone(); - } else { - confirmImport(); - } - }, [confirmImport, exportResult, handleDone, isImported]); - - return ( - - - - - {isFormatError - ? 'Incorrect import format' - : isPreviewMode - ? isImported - ? 'Import results' - : 'Confirm import' - : 'Import'} - - - - {isFormatError ? ( - 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.' - ) : isPreviewMode ? ( -
- {isImported ? null : ( -

- {parsedUsers.length} users detected from the CSV file. Please - confirm the user list below and import. -

- )} - -
- ) : ( -
-

- You need to import the accounts by importing a CSV file in the - correct format. Please download the CSV template. -

- -
- )} -
- - - {isFormatError ? ( - <> -
- CSV template -
- - - ) : isPreviewMode ? ( - <> - - - - ) : ( - <> -
- CSV template -
- - - )} -
-
-
- ); -} diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx new file mode 100644 index 0000000000..63e620d905 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx @@ -0,0 +1,51 @@ +import { WarningIcon } from '@blocksuite/icons/rc'; +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import type { FC } from 'react'; + +interface CsvFormatGuidanceProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; +} + +/** + * Component that displays CSV format guidelines + */ +export const CsvFormatGuidance: FC = ({ + passwordLimits, +}) => { + return ( +
+
+ +
+
+

CSV file includes username, email, and password.

+
    + {[ + `Username (optional): any text.`, + `Email (required): e.g., user@example.com.`, + `Password (optional): ${passwordLimits.minLength}–${passwordLimits.maxLength} characters.`, + ].map((text, index) => ( +
  • + + {text} +
  • + ))} +
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/file-upload-area.tsx similarity index 100% rename from packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx rename to packages/frontend/admin/src/modules/accounts/components/import-users/file-upload-area.tsx diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx new file mode 100644 index 0000000000..455cf38763 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx @@ -0,0 +1,73 @@ +import type { FC, RefObject } from 'react'; + +import type { ParsedUser } from '../../utils/csv-utils'; +import { UserTable } from '../user-table'; +import { CsvFormatGuidance } from './csv-format-guidance'; +import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area'; + +interface ImportPreviewContentProps { + parsedUsers: ParsedUser[]; + isImported: boolean; +} + +/** + * Component for the preview mode content + */ +export const ImportPreviewContent: FC = ({ + parsedUsers, + isImported, +}) => { + return ( +
+ {!isImported && ( +

+ {parsedUsers.length} users detected from the CSV file. Please confirm + the user list below and import. +

+ )} + +
+ ); +}; + +interface ImportInitialContentProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; + fileUploadRef: RefObject; + onFileSelected: (file: File) => Promise; +} + +/** + * Component for the initial import screen + */ +export const ImportInitialContent: FC = ({ + passwordLimits, + fileUploadRef, + onFileSelected, +}) => { + return ( +
+

+ You need to import the accounts by importing a CSV file in the correct + format. Please download the CSV template. +

+ + +
+ ); +}; + +interface ImportErrorContentProps { + message?: string; +} + +/** + * Component for displaying import errors + */ +export const ImportErrorContent: FC = ({ + message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.', +}) => { + return message; +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx new file mode 100644 index 0000000000..ff8101d1a0 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx @@ -0,0 +1,168 @@ +import { Button } from '@affine/admin/components/ui/button'; +import { DialogFooter } from '@affine/admin/components/ui/dialog'; +import type { FC } from 'react'; + +import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils'; + +interface ImportUsersFooterProps { + isFormatError: boolean; + isPreviewMode: boolean; + isImporting: boolean; + isImported: boolean; + resetFormatError: () => void; + cancelImport: () => void; + handleConfirm: () => void; + handleUpload: () => void; + parsedUsers: { + importStatus?: ImportStatus; + }[]; +} + +/** + * Component for the dialog footer with appropriate buttons based on the import state + */ +export const ImportUsersFooter: FC = ({ + isFormatError, + isPreviewMode, + isImporting, + isImported, + resetFormatError, + cancelImport, + handleConfirm, + handleUpload, + parsedUsers, +}) => { + return ( + + {isFormatError ? ( + + ) : isPreviewMode ? ( + + ) : ( + + )} + + ); +}; + +interface FormatErrorFooterProps { + downloadCsvTemplate: () => void; + resetFormatError: () => void; +} + +/** + * Footer component when there's a format error + */ +const FormatErrorFooter: FC = ({ + downloadCsvTemplate, + resetFormatError, +}) => ( + <> +
+ CSV template +
+ + +); + +interface PreviewModeFooterProps { + isImporting: boolean; + isImported: boolean; + cancelImport: () => void; + handleConfirm: () => void; + parsedUsers: { + importStatus?: ImportStatus; + }[]; +} + +/** + * Footer component for preview mode + */ +const PreviewModeFooter: FC = ({ + isImporting, + isImported, + cancelImport, + handleConfirm, + parsedUsers, +}) => ( + <> + + + +); + +interface InitialFooterProps { + isImporting: boolean; + downloadCsvTemplate: () => void; + handleUpload: () => void; +} + +/** + * Footer component for initial state + */ +const InitialFooter: FC = ({ + isImporting, + downloadCsvTemplate, + handleUpload, +}) => ( + <> +
+ CSV template +
+ + +); diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx new file mode 100644 index 0000000000..f63302b347 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx @@ -0,0 +1,109 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@affine/admin/components/ui/dialog'; +import { useEffect, useRef } from 'react'; + +import { useServerConfig } from '../../../common'; +import type { FileUploadAreaRef } from './file-upload-area'; +import { + ImportErrorContent, + ImportInitialContent, + ImportPreviewContent, +} from './import-content'; +import { ImportUsersFooter } from './import-footer'; +import { useImportUsersState } from './use-import-users-state'; + +export interface ImportUsersDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Dialog for importing users from a CSV file + */ +export function ImportUsersDialog({ + open, + onOpenChange, +}: ImportUsersDialogProps) { + const fileUploadRef = useRef(null); + const serverConfig = useServerConfig(); + const passwordLimits = serverConfig.credentialsRequirement.password; + + const handleUpload = () => fileUploadRef.current?.triggerFileUpload(); + + const { + isImporting, + parsedUsers, + isPreviewMode, + isFormatError, + isImported, + handleFileSelected, + cancelImport, + resetFormatError, + handleConfirm, + resetState, + } = useImportUsersState({ + passwordLimits, + onClose: () => onOpenChange(false), + }); + + // Reset all states when dialog is opened + useEffect(() => { + if (open) { + resetState(); + } + }, [open, resetState]); + + return ( + + + + + {isFormatError + ? 'Incorrect import format' + : isPreviewMode + ? isImported + ? 'Import results' + : 'Confirm import' + : 'Import'} + + +
+ {isFormatError ? ( + + ) : isPreviewMode ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts b/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts new file mode 100644 index 0000000000..1a224dbb9c --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts @@ -0,0 +1,193 @@ +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +import { + exportImportResults, + getValidUsersToImport, + ImportStatus, + type ParsedUser, + processCSVFile, + validateUsers, +} from '../../utils/csv-utils'; +import { + useImportUsers, + type UserImportReturnType, +} from '../use-user-management'; + +export interface ImportUsersStateProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; + onClose: () => void; +} + +export function useImportUsersState({ + passwordLimits, + onClose, +}: ImportUsersStateProps) { + const [isImporting, setIsImporting] = useState(false); + const [parsedUsers, setParsedUsers] = useState([]); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [isFormatError, setIsFormatError] = useState(false); + const importUsers = useImportUsers(); + + // Reset all states when dialog is closed + const resetState = useCallback(() => { + setIsPreviewMode(false); + setParsedUsers([]); + setIsImporting(false); + setIsFormatError(false); + }, []); + + const importUsersCallback = useCallback( + (result: UserImportReturnType) => { + const successfulUsers = result.filter( + (user): user is Extract => + user.__typename === 'UserType' + ); + + const failedUsers = result.filter( + ( + user + ): user is Extract< + typeof user, + { __typename: 'UserImportFailedType' } + > => user.__typename === 'UserImportFailedType' + ); + + const successCount = successfulUsers.length; + const failedCount = parsedUsers.length - successCount; + + if (failedCount > 0) { + toast.info( + `Successfully imported ${successCount} users, ${failedCount} failed` + ); + } else { + toast.success(`Successfully imported ${successCount} users`); + } + + const successfulUserEmails = new Set( + successfulUsers.map(user => user.email) + ); + + const failedUserErrorMap = new Map( + failedUsers.map(user => [user.email, user.error]) + ); + + setParsedUsers(prev => { + return prev.map(user => { + if (successfulUserEmails.has(user.email)) { + return { + ...user, + importStatus: ImportStatus.Success, + }; + } + + const errorMessage = failedUserErrorMap.get(user.email) || user.error; + return { + ...user, + importStatus: ImportStatus.Failed, + importError: errorMessage, + }; + }); + }); + + setIsImporting(false); + }, + [parsedUsers.length] + ); + + const handleFileSelected = useCallback( + async (file: File) => { + setIsImporting(true); + try { + await processCSVFile( + file, + usersFromCsv => { + const validatedUsers = validateUsers(usersFromCsv, passwordLimits); + setParsedUsers(validatedUsers); + setIsPreviewMode(true); + setIsImporting(false); + }, + () => { + setIsImporting(false); + setIsFormatError(true); + } + ); + } catch (error) { + console.error('Failed to process file', error); + setIsImporting(false); + setIsFormatError(true); + } + }, + [passwordLimits] + ); + + const confirmImport = useAsyncCallback(async () => { + setIsImporting(true); + try { + const validUsersToImport = getValidUsersToImport(parsedUsers); + + setParsedUsers(prev => + prev.map(user => ({ + ...user, + importStatus: ImportStatus.Processing, + })) + ); + + await importUsers({ users: validUsersToImport }, importUsersCallback); + } catch (error) { + console.error('Failed to import users', error); + toast.error('Failed to import users'); + setIsImporting(false); + } + }, [importUsers, importUsersCallback, parsedUsers]); + + const cancelImport = useCallback(() => { + setIsPreviewMode(false); + setParsedUsers([]); + }, []); + + const resetFormatError = useCallback(() => { + setIsFormatError(false); + }, []); + + // Handle closing the dialog after import is complete + const handleDone = useCallback(() => { + resetState(); + onClose(); + }, [onClose, resetState]); + + // Export failed imports to CSV + const exportResult = useCallback(() => { + exportImportResults(parsedUsers); + }, [parsedUsers]); + + const isImported = !!parsedUsers.some( + user => user.importStatus && user.importStatus !== ImportStatus.Processing + ); + + const handleConfirm = useCallback(() => { + if (isImported) { + exportResult(); + handleDone(); + } else { + confirmImport(); + } + }, [confirmImport, exportResult, handleDone, isImported]); + + return { + isImporting, + parsedUsers, + isPreviewMode, + isFormatError, + isImported, + handleFileSelected, + cancelImport, + resetFormatError, + handleConfirm, + resetState, + }; +} diff --git a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts index fddf2e00fc..15726d8528 100644 --- a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts +++ b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts @@ -47,12 +47,13 @@ export const useCreateUser = () => { const revalidate = useMutateQueryResource(); const create = useAsyncCallback( - async ({ name, email, features }: UserInput) => { + async ({ name, email, password, features }: UserInput) => { try { const account = await createAccount({ input: { name, email, + password: password === '' ? undefined : password, }, }); diff --git a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx index f6d7f36995..5b0aa079e1 100644 --- a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx @@ -4,13 +4,16 @@ import { Label } from '@affine/admin/components/ui/label'; import { Separator } from '@affine/admin/components/ui/separator'; import { Switch } from '@affine/admin/components/ui/switch'; import type { FeatureType } from '@affine/graphql'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { ChevronRightIcon } from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { useServerConfig } from '../../common'; import { RightPanelHeader } from '../../header'; import type { UserInput, UserType } from '../schema'; +import { validateEmails, validatePassword } from '../utils/csv-utils'; import { useCreateUser, useUpdateUser } from './use-user-management'; type UserFormProps = { @@ -20,6 +23,7 @@ type UserFormProps = { onConfirm: (user: UserInput) => void; onValidate: (user: Partial) => boolean; actions?: React.ReactNode; + showOption?: boolean; }; function UserForm({ @@ -29,6 +33,7 @@ function UserForm({ onConfirm, onValidate, actions, + showOption, }: UserFormProps) { const serverConfig = useServerConfig(); @@ -36,9 +41,10 @@ function UserForm({ () => ({ name: defaultValue?.name ?? '', email: defaultValue?.email ?? '', + password: defaultValue?.password ?? '', features: defaultValue?.features ?? [], }), - [defaultValue?.email, defaultValue?.features, defaultValue?.name] + [defaultValue] ); const [changes, setChanges] = useState>(defaultUser); @@ -68,7 +74,8 @@ function UserForm({ // @ts-expect-error checked onConfirm(changes); - }, [canSave, changes, onConfirm]); + setChanges(defaultUser); + }, [canSave, changes, defaultUser, onConfirm]); const onFeatureChanged = useCallback( (feature: FeatureType, checked: boolean) => { @@ -116,6 +123,19 @@ function UserForm({ onChange={setField} placeholder="Enter email address" /> + {showOption && ( + <> + + + + )}
@@ -167,12 +187,14 @@ function ToggleItem({ function InputItem({ label, field, + optional, value, onChange, placeholder, }: { label: string; field: keyof UserInput; + optional?: boolean; value?: string; onChange: (field: keyof UserInput, value: string) => void; placeholder?: string; @@ -185,9 +207,20 @@ function InputItem({ ); return ( -
-