refactor(editor): add a layer of ui-logic to enhance type safety (#12511)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced modular UI logic layers for Kanban and Table views, enhancing maintainability and scalability.
  - Added new CSS-in-JS style modules for database blocks and table views, improving visual consistency.
  - Expanded telemetry event tracking for database views, properties, filters, and groups.
  - Added utility functions for lazy initialization and cached computed values.

- **Refactor**
  - Unified logic and state management across Kanban and Table views by replacing direct component dependencies with logic-centric architecture.
  - Updated components and widgets to use the new logic-based approach for state, selection, and event handling.
  - Replaced inline styles with CSS classes; updated class names to align with new component structure.
  - Centralized state access through UI logic instances, eliminating direct DOM queries and simplifying dependencies.
  - Consolidated Kanban and Table view presets effects for streamlined initialization.
  - Replaced Lit reactive state with Preact signals in multiple components for improved reactivity.
  - Split monolithic components into separate logic and UI classes for clearer separation of concerns.
  - Removed obsolete components and consolidated exports for cleaner API surface.

- **Bug Fixes**
  - Enhanced selection and interaction reliability in database cells and views.
  - Fixed scrolling issues on mobile table views for improved compatibility.

- **Chores**
  - Updated end-to-end test selectors to reflect new component names and structure.
  - Removed deprecated utilities and cleaned up unused imports.

- **Documentation**
  - Improved type definitions and public API exports for better developer experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
zzj3720
2025-05-27 09:36:44 +00:00
parent 9bf86e3f61
commit 0f1a3c212d
109 changed files with 2625 additions and 2436 deletions
@@ -70,3 +70,19 @@ export const dividerV = css({
backgroundColor: 'var(--affine-divider-color)',
margin: '0 8px',
});
export const dv = {
p2,
p4,
p8,
hover,
icon16,
icon20,
border,
round4,
round8,
color2,
shadow2,
dividerH,
dividerV,
};
+137 -160
View File
@@ -2,42 +2,43 @@ import type {
DatabaseAllEvents,
EventTraceFn,
} from '@blocksuite/affine-shared/services';
import type { DisposableMember } from '@blocksuite/global/disposable';
import { IS_MOBILE } from '@blocksuite/global/env';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import {
type Clipboard,
type EventName,
ShadowlessElement,
type UIEventHandler,
} from '@blocksuite/std';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { css, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { ref } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataViewSelection, DataViewSelectionState } from './types.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewInstance, DataViewProps } from './view/types.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
import type { DataViewWidget } from './widget/index.js';
type ViewProps = {
view: SingleView;
selection$: ReadonlySignal<DataViewSelectionState>;
setSelection: (selection?: DataViewSelectionState) => void;
bindHotkey: DataViewProps['bindHotkey'];
handleEvent: DataViewProps['handleEvent'];
};
export type DataViewRendererConfig = Pick<
DataViewProps,
| 'bindHotkey'
| 'handleEvent'
| 'virtualPadding$'
| 'clipboard'
| 'dataSource'
| 'headerWidget'
| 'onDrag'
| 'notification'
> & {
export type DataViewRendererConfig = {
clipboard: Clipboard;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
};
virtualPadding$: ReadonlySignal<number>;
headerWidget: DataViewWidget | undefined;
handleEvent: (name: EventName, handler: UIEventHandler) => DisposableMember;
bindHotkey: (hotkeys: Record<string, UIEventHandler>) => DisposableMember;
dataSource: DataSource;
selection$: ReadonlySignal<DataViewSelection | undefined>;
setSelection: (selection: DataViewSelection | undefined) => void;
eventTrace: EventTraceFn<DatabaseAllEvents>;
@@ -52,7 +53,104 @@ export type DataViewRendererConfig = Pick<
};
};
export class DataViewRenderer extends SignalWatcher(
export class DataViewRootUILogic {
private get dataSource() {
return this.config.dataSource;
}
private get viewManager() {
return this.dataSource.viewManager;
}
private createDataViewUILogic(viewId: string): DataViewUILogicBase {
const view = this.viewManager.viewGet(viewId);
if (!view) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.DatabaseBlockError,
`View ${viewId} not found`
);
}
const pcLogic = view.meta.renderer.pcLogic;
const mobileLogic = view.meta.renderer.mobileLogic;
const logic = (IS_MOBILE ? mobileLogic : pcLogic) ?? pcLogic;
return new (logic(view))(this, view);
}
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.list.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();
get selection$() {
return this.config.selection$;
}
setSelection(selection?: DataViewSelection) {
this.config.setSelection(selection);
}
constructor(public readonly config: DataViewRendererConfig) {}
get dataViewRenderer() {
return this._uiRef.value;
}
readonly currentViewId$ = computed(() => {
return this.dataSource.viewManager.currentViewId$.value;
});
readonly currentView$ = computed(() => {
const currentViewId = this.currentViewId$.value;
if (!currentViewId) {
return;
}
return this.viewsMap$.value[currentViewId];
});
focusFirstCell = () => {
this.currentView$.value?.focusFirstCell();
};
openDetailPanel = (ops: {
view: SingleView;
rowId: string;
onClose?: () => void;
}) => {
const openDetailPanel = this.config.detailPanelConfig.openDetailPanel;
const target = this.dataViewRenderer;
if (openDetailPanel && target) {
openDetailPanel(target, {
view: ops.view,
rowId: ops.rowId,
})
.catch(console.error)
.finally(ops.onClose);
}
};
setupViewChangeListener() {
let preId: string | undefined = undefined;
return this.currentViewId$.subscribe(current => {
if (current !== preId) {
this.config.setSelection(undefined);
}
preId = current;
});
}
render() {
return html` <affine-data-view-renderer
${ref(this._uiRef)}
.logic="${this}"
></affine-data-view-renderer>`;
}
}
export class DataViewRootUI extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
@@ -63,63 +161,14 @@ export class DataViewRenderer extends SignalWatcher(
}
`;
private readonly _view = signal<DataViewInstance>();
@property({ attribute: false })
accessor config!: DataViewRendererConfig;
accessor logic!: DataViewRootUILogic;
private readonly currentViewId$ = computed(() => {
return this.config.dataSource.viewManager.currentViewId$.value;
});
viewMap$ = computed(() => {
const manager = this.config.dataSource.viewManager;
return Object.fromEntries(
manager.views$.value.map(view => [view, manager.viewGet(view)])
);
});
currentViewConfig$ = computed<ViewProps | undefined>(() => {
const currentViewId = this.currentViewId$.value;
if (!currentViewId) {
return;
}
const view = this.viewMap$.value[currentViewId];
if (!view) {
return;
}
return {
view: view,
selection$: computed(() => {
const selection$ = this.config.selection$;
if (selection$.value?.viewId === currentViewId) {
return selection$.value;
}
return;
}),
setSelection: selection => {
this.config.setSelection(selection);
},
handleEvent: (name, handler) =>
this.config.handleEvent(name, context => {
return handler(context);
}),
bindHotkey: hotkeys =>
this.config.bindHotkey(
Object.fromEntries(
Object.entries(hotkeys).map(([key, fn]) => [
key,
ctx => {
return fn(ctx);
},
])
)
),
};
});
@state()
accessor currentView: string | undefined = undefined;
focusFirstCell = () => {
this.view?.focusFirstCell();
this.logic.focusFirstCell();
};
openDetailPanel = (ops: {
@@ -127,72 +176,12 @@ export class DataViewRenderer extends SignalWatcher(
rowId: string;
onClose?: () => void;
}) => {
const openDetailPanel = this.config.detailPanelConfig.openDetailPanel;
if (openDetailPanel) {
openDetailPanel(this, {
view: ops.view,
rowId: ops.rowId,
})
.catch(console.error)
.finally(ops.onClose);
}
this.logic.openDetailPanel(ops);
};
get view() {
return this._view.value;
}
private renderView(viewData?: ViewProps) {
if (!viewData) {
return;
}
const props: DataViewProps = {
dataViewEle: this,
headerWidget: this.config.headerWidget,
onDrag: this.config.onDrag,
dataSource: this.config.dataSource,
virtualPadding$: this.config.virtualPadding$,
clipboard: this.config.clipboard,
notification: this.config.notification,
view: viewData.view,
selection$: viewData.selection$,
setSelection: viewData.setSelection,
bindHotkey: viewData.bindHotkey,
handleEvent: viewData.handleEvent,
eventTrace: (key, params) => {
this.config.eventTrace(key, {
...(params as DatabaseAllEvents[typeof key]),
viewId: viewData.view.id,
viewType: viewData.view.type,
});
},
};
const renderer = viewData.view.meta.renderer;
const view =
(IS_MOBILE ? renderer.mobileView : renderer.view) ?? renderer.view;
return keyed(
viewData.view.id,
renderUniLit(
view,
{ props },
{
ref: this._view,
}
)
);
}
override connectedCallback() {
super.connectedCallback();
let preId: string | undefined = undefined;
this.disposables.add(
this.currentViewId$.subscribe(current => {
if (current !== preId) {
this.config.setSelection(undefined);
}
preId = current;
})
);
this.disposables.add(this.logic.setupViewChangeListener());
}
override render() {
@@ -201,34 +190,22 @@ export class DataViewRenderer extends SignalWatcher(
'data-view-root': true,
'prevent-reference-popup': true,
});
const currentView = this.logic.currentView$.value;
if (!currentView) {
return;
}
return html`
<div style="display: contents" class="${containerClass}">
${this.renderView(this.currentViewConfig$.value)}
${renderUniLit(currentView.renderer, {
logic: currentView,
})}
</div>
`;
}
@state()
accessor currentView: string | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-renderer': DataViewRenderer;
}
}
export class DataView {
private readonly _ref = createRef<DataViewRenderer>();
get expose() {
return this._ref.value?.view;
}
render(props: DataViewRendererConfig) {
return html` <affine-data-view-renderer
${ref(this._ref)}
.config="${props}"
></affine-data-view-renderer>`;
'affine-data-view-renderer': DataViewRootUI;
}
}
@@ -2,7 +2,7 @@ import { DataViewPropertiesSettingView } from './common/properties.js';
import { Button } from './component/button/button.js';
import { Overflow } from './component/overflow/overflow.js';
import { MultiTagSelect, MultiTagView } from './component/tags/index.js';
import { DataViewRenderer } from './data-view.js';
import { DataViewRootUI } from './data-view.js';
import { RecordDetail } from './detail/detail.js';
import { RecordField } from './detail/field.js';
import { VariableRefView } from './expression/ref/ref-view.js';
@@ -15,7 +15,7 @@ import { AffineLitIcon, UniAnyRender, UniLit } from './index.js';
import { AnyRender } from './utils/uni-component/render-template.js';
export function coreEffects() {
customElements.define('affine-data-view-renderer', DataViewRenderer);
customElements.define('affine-data-view-renderer', DataViewRootUI);
customElements.define('any-render', AnyRender);
customElements.define(
'data-view-properties-setting',
@@ -1,7 +1,7 @@
export * from './common/index.js';
export * from './component/index.js';
export { DataSourceBase } from './data-source/base.js';
export { DataView } from './data-view.js';
export { DataViewRootUILogic } from './data-view.js';
export * from './filter/index.js';
export * from './group-by';
export * from './logical/index.js';
@@ -183,7 +183,6 @@ export class TypeSystem {
// eslint-disable-next-line sonarjs/no-collapsible-if
if (realArg != null) {
if (!this._unify(newCtx, realArg, arg)) {
console.log('arg', realArg, arg);
return;
}
}
@@ -0,0 +1,32 @@
import { computed, type ReadonlySignal } from '@preact/signals-core';
export const cacheComputed = <T>(
ids: ReadonlySignal<string[]>,
create: (id: string) => T
) => {
const cache = new Map<string, T>();
const getOrCreate = (id: string): T => {
if (cache.has(id)) {
return cache.get(id)!;
}
const value = create(id);
if (value) {
cache.set(id, value);
}
return value;
};
return {
getOrCreate,
list: computed<T[]>(() => {
const list = ids.value;
const keys = new Set(cache.keys());
for (const [cachedId] of cache) {
keys.delete(cachedId);
}
for (const id of keys) {
cache.delete(id);
}
return list.map(id => getOrCreate(id));
}),
};
};
@@ -1,2 +1,3 @@
export * from './lazy.js';
export * from './uni-component/index.js';
export * from './uni-icon.js';
@@ -0,0 +1,11 @@
export const lazy = <T>(fn: () => T): { value: T } => {
let data: { value: T } | undefined;
return {
get value() {
if (!data) {
data = { value: fn() };
}
return data.value;
},
};
};
@@ -1,17 +1,106 @@
import type {
DatabaseAllEvents,
DatabaseAllViewEvents,
EventTraceFn,
} from '@blocksuite/affine-shared/services';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import type { DisposableMember } from '@blocksuite/global/disposable';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import {
type EventName,
ShadowlessElement,
type UIEventHandler,
} from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import { property } from 'lit/decorators.js';
import type { DataViewRootUILogic } from '../data-view.js';
import type { DataViewSelection } from '../types.js';
import type { SingleView } from '../view-manager/single-view.js';
import type { DataViewWidget } from '../widget/index.js';
import type { DataViewInstance, DataViewProps } from './types.js';
export abstract class DataViewBase<
T extends SingleView = SingleView,
Selection extends DataViewSelection = DataViewSelection,
> extends SignalWatcher(WithDisposable(ShadowlessElement)) {
abstract expose: DataViewInstance;
@property({ attribute: false })
accessor props!: DataViewProps<T, Selection>;
accessor props!: DataViewProps<Selection>;
}
export abstract class DataViewUIBase<
Logic extends DataViewUILogicBase = DataViewUILogicBase,
> extends SignalWatcher(WithDisposable(ShadowlessElement)) {
@property({ attribute: false })
accessor logic!: Logic;
}
export abstract class DataViewUILogicBase<
T extends SingleView = SingleView,
Selection extends DataViewSelection = DataViewSelection,
> {
constructor(
public readonly root: DataViewRootUILogic,
public readonly view: T
) {}
get headerWidget(): DataViewWidget | undefined {
return this.root.config.headerWidget;
}
bindHotkey(hotkeys: Record<string, UIEventHandler>): DisposableMember {
return this.root.config.bindHotkey(
Object.fromEntries(
Object.entries(hotkeys).map(([key, fn]) => [
key,
ctx => {
return fn(ctx);
},
])
)
);
}
handleEvent(name: EventName, handler: UIEventHandler): DisposableMember {
return this.root.config.handleEvent(name, context => {
return handler(context);
});
}
setSelection(selection?: Selection): void {
this.root.setSelection(selection);
}
selection$ = computed<Selection | undefined>(() => {
const selection$ = this.root.selection$;
if (selection$.value?.viewId === this.view.id) {
return selection$.value as Selection | undefined;
}
return;
});
eventTrace: EventTraceFn<DatabaseAllViewEvents> = (key, params) => {
this.root.config.eventTrace(key, {
...(params as DatabaseAllEvents[typeof key]),
viewId: this.view.id,
viewType: this.view.type,
});
};
abstract clearSelection: () => void;
abstract addRow: (position: InsertToPosition) => string | undefined;
abstract focusFirstCell: () => void;
abstract showIndicator: (evt: MouseEvent) => boolean;
abstract hideIndicator: () => void;
abstract moveTo: (id: string, evt: MouseEvent) => void;
abstract renderer: UniComponent<{
logic: DataViewUILogicBase<T, Selection>;
}>;
}
type Constructor<T extends abstract new (...args: any) => any> = new (
...args: ConstructorParameters<T>
) => InstanceType<T>;
export type DataViewUILogicBaseConstructor = Constructor<
typeof DataViewUILogicBase
>;
@@ -2,6 +2,7 @@ import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { SingleView } from '../view-manager/single-view.js';
import type { ViewManager } from '../view-manager/view-manager.js';
import type { DataViewUILogicBaseConstructor } from './data-view-base.js';
import type { DataViewInstance, DataViewProps } from './types.js';
export type BasicViewDataType<
@@ -48,9 +49,10 @@ type DataViewComponent = UniComponent<
>;
export interface DataViewRendererConfig {
view: DataViewComponent;
mobileView?: DataViewComponent;
icon: UniComponent;
pcLogic: (view: SingleView) => DataViewUILogicBaseConstructor;
mobileLogic?: (view: SingleView) => DataViewUILogicBaseConstructor;
}
export type ViewMeta<
@@ -1,3 +1,4 @@
export * from './convert.js';
export * from './data-view.js';
export * from './data-view-base.js';
export * from './types.js';
@@ -4,44 +4,21 @@ import type {
} from '@blocksuite/affine-shared/services';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import type { Disposable } from '@blocksuite/global/disposable';
import type { Clipboard, EventName, UIEventHandler } from '@blocksuite/std';
import type { EventName, UIEventHandler } from '@blocksuite/std';
import type { ReadonlySignal } from '@preact/signals-core';
import type { DataSource } from '../common/index.js';
import type { DataViewRenderer } from '../data-view.js';
import type { DataViewSelection } from '../types.js';
import type { SingleView } from '../view-manager/index.js';
import type { DataViewWidget } from '../widget/index.js';
export interface DataViewProps<
T extends SingleView = SingleView,
Selection extends DataViewSelection = DataViewSelection,
> {
dataViewEle: DataViewRenderer;
headerWidget?: DataViewWidget;
view: T;
dataSource: DataSource;
bindHotkey: (hotkeys: Record<string, UIEventHandler>) => Disposable;
handleEvent: (name: EventName, handler: UIEventHandler) => Disposable;
setSelection: (selection?: Selection) => void;
selection$: ReadonlySignal<Selection | undefined>;
virtualPadding$: ReadonlySignal<number>;
onDrag?: (evt: MouseEvent, id: string) => () => void;
clipboard: Clipboard;
notification: {
toast: (message: string) => void;
};
eventTrace: EventTraceFn<DatabaseAllViewEvents>;
}
@@ -1,8 +1,10 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { DataViewInstance } from '../view/types.js';
import type { DataViewUILogicBase } from '../view/data-view-base.js';
export type DataViewWidgetProps = {
dataViewInstance: DataViewInstance;
export type DataViewWidgetProps<
ViewLogic extends DataViewUILogicBase = DataViewUILogicBase,
> = {
dataViewLogic: ViewLogic;
};
export type DataViewWidget = UniComponent<DataViewWidgetProps>;
@@ -2,30 +2,27 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { property } from 'lit/decorators.js';
import type { DataViewInstance } from '../view/types.js';
import type { SingleView } from '../view-manager/index.js';
import type { DataViewUILogicBase } from '../view/data-view-base.js';
import type { DataViewWidgetProps } from './types.js';
export class WidgetBase<View extends SingleView = SingleView>
export class WidgetBase<
ViewLogic extends DataViewUILogicBase = DataViewUILogicBase,
>
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements DataViewWidgetProps
implements DataViewWidgetProps<ViewLogic>
{
get dataSource() {
return this.view.manager.dataSource;
return this.viewManager.dataSource;
}
get view() {
return this.dataViewInstance.view;
return this.dataViewLogic.view;
}
get viewManager() {
return this.view.manager;
}
get viewMethods() {
return this.dataViewInstance;
}
@property({ attribute: false })
accessor dataViewInstance!: DataViewInstance<View>;
accessor dataViewLogic!: ViewLogic;
}