feat(editor): add created-time and created-by property for database block (#12156)

close: BS-3431

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

- **New Features**
  - Added "Created By" property type and cell renderer, displaying creator's avatar and name in database blocks.
  - Introduced "Created Time" property type and cell renderer, showing formatted creation timestamps.

- **Improvements**
  - Enhanced table and Kanban views with improved column and row movement, hiding, and statistics capabilities.
  - Streamlined property and row management with unified object handling and reactive signals for better performance and reliability.
  - Improved avatar display logic to handle removed or unnamed users gracefully.
  - Refactored property and row APIs to consolidate access patterns and support reactive updates.
  - Updated icon retrieval and reactive value access for improved UI responsiveness in database and Kanban cells.
  - Consolidated property and cell access methods to use "get or create" patterns ensuring consistent data availability.
  - Added locking mechanism to stabilize computed signals during locked states.
  - Modularized table and Kanban column and row abstractions for better encapsulation and maintainability.

- **Bug Fixes**
  - Corrected row and column deletion, movement, and selection behaviors across table and Kanban views.
  - Fixed issues with property and row referencing, ensuring consistent handling of identifiers and objects.
  - Removed debugging logs and fixed method calls to align with updated APIs.

- **Style**
  - Updated and simplified table column header styles for a cleaner appearance.

- **Chores**
  - Added `@emotion/css` dependency for styling support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
zzj3720
2025-05-08 11:35:36 +00:00
parent 7c8b977bf9
commit 6689bd1914
76 changed files with 1168 additions and 1042 deletions
@@ -0,0 +1,31 @@
import { propertyType, t } from '@blocksuite/affine/blocks/database';
import zod from 'zod';
export const createdByColumnType = propertyType('created-by');
export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
name: 'Created By',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
fixed: {
defaultData: {},
defaultOrder: 'end',
defaultShow: false,
},
rawValue: {
schema: zod.string().nullable(),
default: () => null,
toString: ({ value }) => value ?? '',
fromString: () => {
return { value: null };
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
jsonValue: {
schema: zod.string().nullable(),
isEmpty: () => false,
type: () => t.string.instance(),
},
});
@@ -0,0 +1,130 @@
import { Avatar, uniReactRoot } from '@affine/component';
import {
type CellRenderProps,
createIcon,
type DataViewCellLifeCycle,
HostContextKey,
} from '@blocksuite/affine/blocks/database';
import {
UserProvider,
type UserService,
} from '@blocksuite/affine/shared/services';
import { css } from '@emotion/css';
import {
forwardRef,
type ForwardRefRenderFunction,
type ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import { useSignalValue } from '../../../../modules/doc-info/utils';
import { createdByPropertyModelConfig } from './define';
const cellContainer = css({
width: '100%',
position: 'relative',
gap: '6px',
display: 'flex',
flexWrap: 'wrap',
overflow: 'hidden',
});
const memberPreviewContainer = css({
display: 'flex',
alignItems: 'center',
gap: '4px',
overflow: 'hidden',
});
const memberName = css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '14px',
lineHeight: '22px',
});
const avatar = css({
flexShrink: 0,
});
const CreatedByCellComponent: ForwardRefRenderFunction<
DataViewCellLifeCycle,
CellRenderProps<{}, string | null, string | null>
> = (props, ref): ReactNode => {
useImperativeHandle(
ref,
() => ({
beforeEnterEditMode: () => {
return false;
},
beforeExitEditingMode: () => {},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
}),
[]
);
const host = props.cell.view.contextGet(HostContextKey);
const userService = host?.std.getOptional(UserProvider);
const memberId = useSignalValue(props.cell.value$);
if (!memberId) {
return null;
}
return (
<div style={{ overflow: 'hidden' }}>
<div className={cellContainer}>
<MemberPreview
key={memberId}
memberId={memberId}
userService={userService}
/>
</div>
</div>
);
};
const useMemberInfo = (
id: string,
userService: UserService | null | undefined
) => {
useEffect(() => {
userService?.revalidateUserInfo(id);
}, [id, userService]);
return useSignalValue(userService?.userInfo$(id));
};
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={24}
/>
<div className={memberName}>
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
</div>
</div>
);
};
const CreatedByCell = forwardRef(CreatedByCellComponent);
export const createdByPropertyConfig =
createdByPropertyModelConfig.createPropertyMeta({
icon: createIcon('UserIcon'),
cellRenderer: {
view: uniReactRoot.createUniComponent(CreatedByCell),
},
});
@@ -1,9 +1,11 @@
import type { PropertyMetaConfig } from '@blocksuite/affine/blocks/database';
import { createdByPropertyConfig } from './created-by/view';
import { filePropertyConfig } from './file/view';
import { memberPropertyConfig } from './member/view';
export const propertiesPresets: PropertyMetaConfig<string, any, any, any>[] = [
filePropertyConfig,
memberPropertyConfig,
createdByPropertyConfig,
];
@@ -205,7 +205,11 @@ export const MemberListItem = (props: {
data-selected={isSelected ? 'true' : 'false'}
>
<div className={styles.avatar}>
<Avatar url={member.avatar} size={24} />
<Avatar
name={member.removed ? undefined : (member.name ?? undefined)}
url={member.avatar}
size={24}
/>
</div>
<div className={styles.memberName}>{member.name}</div>
</div>
@@ -228,6 +232,7 @@ export const MemberPreview = ({
return (
<div className={styles.memberPreviewContainer}>
<Avatar
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
className={styles.avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={16}
@@ -161,6 +161,7 @@ const MemberPreview = ({
return (
<div className={styles.memberPreviewContainer}>
<Avatar
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
className={styles.avatar}
url={!userInfo.removed ? userInfo.avatar : undefined}
size={24}