feat(editor): add grouping support for member property of the database block (#12243)

close: BS-3433

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

- **New Features**
  - Introduced advanced group-by configurations for database blocks with user membership support.
  - Added a React hook for fetching and displaying user information in member-related components.
  - Enabled dynamic user and membership data types in database properties.

- **Improvements**
  - Replaced context-based service access with a dependency injection system for shared services and state.
  - Enhanced type safety and consistency across group-by UI components and data handling.
  - Centralized group data management with a new Group class and refined group trait logic.

- **Bug Fixes**
  - Improved reliability and consistency in retrieving and rendering user and group information.

- **Style**
  - Removed obsolete member selection styles for cleaner UI code.

- **Chores**
  - Registered external group-by configurations via dependency injection.
  - Refactored internal APIs for data sources, views, and group-by matchers to use service-based patterns.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
zzj3720
2025-05-13 13:53:37 +00:00
parent fe2fc892df
commit a2a90df276
59 changed files with 702 additions and 374 deletions

View File

@@ -0,0 +1,147 @@
import { Avatar, uniReactRoot } from '@affine/component';
import {
createGroupByConfig,
type GroupRenderProps,
t,
ungroups,
} from '@blocksuite/affine/blocks/database';
import type { UserService } from '@blocksuite/affine-shared/services';
import { useMemberInfo } from '../hooks/use-member-info';
import {
avatar,
memberName,
memberPreviewContainer,
} from '../properties/member/style.css';
const MemberPreview = ({
memberId,
userService,
}: {
memberId: string;
userService: UserService | null | undefined;
}) => {
const userInfo = useMemberInfo(memberId, userService);
if (!userInfo) {
return null;
}
return (
<div className={memberPreviewContainer}>
<Avatar
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
className={avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={20}
/>
<div className={memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
</div>
);
};
const MemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.user.is(tType)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.data?.userService}
></MemberPreview>
);
};
const MultiMemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
const tType = props.group.tType;
if (!t.array.is(tType) || !t.user.is(tType.element)) return 'Ungroup';
const memberId = props.group.value;
if (memberId == null) return 'Ungroup';
return (
<MemberPreview
memberId={memberId}
userService={tType.element.data?.userService}
></MemberPreview>
);
};
export const groupByConfigList = [
createGroupByConfig({
name: 'member',
matchType: t.user.instance(),
groupName: (type, value: string | null) => {
if (t.user.is(type) && typeof value === 'string') {
const userService = type.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: () => {
return [ungroups];
},
valuesGroup: value => {
if (typeof value !== 'string') {
return [ungroups];
}
return [
{
key: value,
value: value,
},
];
},
view: uniReactRoot.createUniComponent(MemberGroupView),
}),
createGroupByConfig({
name: 'multi-member',
matchType: t.array.instance(t.user.instance()),
groupName: (_type, value: string | null) => {
if (
t.array.is(_type) &&
t.user.is(_type.element) &&
typeof value === 'string'
) {
const userService = _type.element.data?.userService;
if (userService) {
const userInfo = userService.userInfo$(value).value;
if (userInfo && !userInfo?.removed) {
return userInfo.name ?? 'Unnamed';
}
}
}
return '';
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value, _type) => {
if (!Array.isArray(value)) {
return [ungroups];
}
return value.map(id => ({
key: id,
value: id,
}));
},
addToGroup: (value, old) => {
if (value == null) {
return old;
}
return Array.isArray(old) ? [...old, value] : [value];
},
removeFromGroup: (value, old) => {
if (Array.isArray(old)) {
return old.filter(v => v !== value);
}
return old;
},
view: uniReactRoot.createUniComponent(MultiMemberGroupView),
}),
];

View File

@@ -0,0 +1,14 @@
import type { UserService } from '@blocksuite/affine-shared/services';
import { useEffect } from 'react';
import { useSignalValue } from '../../../modules/doc-info/utils';
export const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};

View File

@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const createdByColumnType = propertyType('created-by');
@@ -26,6 +34,19 @@ export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
jsonValue: {
schema: zod.string().nullable(),
isEmpty: () => false,
type: () => t.string.instance(),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.user.instance(
userListService && userService
? {
userService,
userListService,
}
: undefined
);
},
},
});

View File

@@ -3,7 +3,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserProvider,
@@ -14,11 +14,11 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import { createdByPropertyModelConfig } from './define';
const cellContainer = css({
@@ -64,7 +64,7 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
}),
[]
);
const host = props.cell.view.contextGet(HostContextKey);
const host = props.cell.view.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const memberId = useSignalValue(props.cell.value$);
if (!memberId) {
@@ -83,16 +83,6 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
userService,

View File

@@ -7,7 +7,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import type { BlobEngine } from '@blocksuite/affine/sync';
@@ -224,8 +224,8 @@ class FileCellManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
this.blobSync = this.cell?.view?.contextGet
? this.cell.view.contextGet(HostContextKey)?.store.blobSync
this.blobSync = this.cell?.view?.serviceGet
? this.cell.view.serviceGet(EditorHostKey)?.store.blobSync
: undefined;
this.fileUploadManager = this.blobSync

View File

@@ -1,4 +1,12 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import {
EditorHostKey,
propertyType,
t,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
UserProvider,
} from '@blocksuite/affine/shared/services';
import zod from 'zod';
export const memberColumnType = propertyType('member');
@@ -31,7 +39,21 @@ export const memberPropertyModelConfig = memberColumnType.modelConfig({
},
jsonValue: {
schema: MemberCellJsonValueTypeSchema,
type: () => t.array.instance(t.string.instance()),
type: ({ dataSource }) => {
const host = dataSource.serviceGet(EditorHostKey);
const userService = host?.std.getOptional(UserProvider);
const userListService = host?.std.getOptional(UserListProvider);
return t.array.instance(
t.user.instance(
userListService && userService
? {
userService: userService,
userListService: userListService,
}
: undefined
)
);
},
isEmpty: ({ value }) => value.length === 0,
},
});

View File

@@ -16,6 +16,7 @@ import {
import { useSignalValue } from '../../../../../modules/doc-info/utils';
import { Spinner } from '../../../components/loading';
import { useMemberInfo } from '../../../hooks/use-member-info';
import * as styles from './style.css';
type BaseOptions = {
@@ -172,13 +173,6 @@ class MemberManager {
};
}
export const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
export const MemberListItem = (props: {
member: ExistedUserInfo;
memberManager: MemberManager;
@@ -225,7 +219,7 @@ export const MemberPreview = ({
memberManager: MemberManager;
onDelete?: () => void;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}

View File

@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberPopoverContainer = style({
@@ -10,45 +9,10 @@ export const memberPopoverContent = style({
padding: '0',
});
export const searchContainer = style({
padding: '12px 12px 8px 12px',
});
export const searchInput = style({
width: '100%',
});
export const memberListContainer = style({
display: 'flex',
flexDirection: 'column',
maxHeight: '300px',
overflow: 'auto',
});
export const memberItem = style({
display: 'flex',
justifyContent: 'space-between',
padding: '8px 12px',
gap: '8px',
overflow: 'hidden',
cursor: 'pointer',
borderRadius: '4px',
transition: 'background-color 0.2s ease',
':hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
':active': {
backgroundColor: cssVarV2.layer.background.secondary,
},
});
export const memberItemContent = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
});
export const memberName = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -70,21 +34,6 @@ export const avatar = style({
flexShrink: 0,
});
export const loadingContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
});
export const noResultContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '16px',
color: cssVarV2.text.secondary,
});
export const memberPreviewContainer = style({
display: 'flex',
alignItems: 'center',

View File

@@ -4,7 +4,7 @@ import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
EditorHostKey,
} from '@blocksuite/affine/blocks/database';
import {
UserListProvider,
@@ -17,12 +17,12 @@ import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
useMemo,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { useMemberInfo } from '../../hooks/use-member-info';
import type {
MemberCellJsonValueType,
MemberCellRawValueType,
@@ -55,7 +55,7 @@ class MemberManager {
this.cell = props.cell;
this.selectCurrentCell = props.selectCurrentCell;
this.isEditing = props.isEditing$;
const host = this.cell.view.contextGet(HostContextKey);
const host = this.cell.view.serviceGet(EditorHostKey);
this.userService = host?.std.getOptional(UserProvider);
this.userListService = host?.std.getOptional(UserListProvider);
}
@@ -140,13 +140,6 @@ const MemberCellComponent: ForwardRefRenderFunction<
);
};
const useMemberInfo = (id: string, memberManager: MemberManager) => {
useEffect(() => {
memberManager.userService?.revalidateUserInfo(id);
}, [id, memberManager.userService]);
return useSignalValue(memberManager.userService?.userInfo$(id));
};
const MemberPreview = ({
memberId,
memberManager,
@@ -154,7 +147,7 @@ const MemberPreview = ({
memberId: string;
memberManager: MemberManager;
}) => {
const userInfo = useMemberInfo(memberId, memberManager);
const userInfo = useMemberInfo(memberId, memberManager.userService);
if (!userInfo) {
return null;
}

View File

@@ -1,12 +1,20 @@
import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database';
import {
DatabaseBlockDataSource,
ExternalGroupByConfigProvider,
} from '@blocksuite/affine/blocks/database';
import type { ExtensionType } from '@blocksuite/affine/store';
import { groupByConfigList } from '../database-block/group-by';
import { propertiesPresets } from '../database-block/properties';
export function patchDatabaseBlockConfigService(): ExtensionType {
//TODO use service
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
return {
setup: () => {},
setup: di => {
groupByConfigList.forEach(config => {
di.addValue(ExternalGroupByConfigProvider(config.name), config);
});
},
};
}