mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
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:
@@ -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),
|
||||
}),
|
||||
];
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user