;
params?: URLSearchParams;
+ className?: string;
}) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
@@ -108,7 +111,7 @@ export function AffinePageReference({
ref={ref}
to={`/${pageId}${query}`}
onClick={onClick}
- className={styles.pageReferenceLink}
+ className={clsx(styles.pageReferenceLink, className)}
>
{Wrapper ? {el} : el}
diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts
index a858d161af78a..9c6903a34c84d 100644
--- a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts
+++ b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.css.ts
@@ -14,11 +14,15 @@ export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
+ marginBottom: 20,
+ padding: 2,
});
export const titleStyle = style({
- fontSize: cssVar('fontH6'),
+ fontSize: cssVar('fontH2'),
fontWeight: '600',
+ minHeight: 42,
+ padding: 0,
});
export const rowNameContainer = style({
diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx
index 47e28125d1634..e96009461386f 100644
--- a/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx
+++ b/packages/frontend/core/src/components/doc-properties/info-modal/info-modal.tsx
@@ -4,10 +4,14 @@ import {
type InlineEditHandle,
Menu,
Modal,
- PropertyCollapsible,
+ PropertyCollapsibleContent,
+ PropertyCollapsibleSection,
Scrollable,
} from '@affine/component';
-import { DocInfoService } from '@affine/core/modules/doc-info';
+import {
+ DocDatabaseBacklinkInfo,
+ DocInfoService,
+} from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
@@ -27,7 +31,6 @@ import { CreatePropertyMenuItems } from '../menu/create-doc-property';
import { DocPropertyRow } from '../table';
import * as styles from './info-modal.css';
import { LinksRow } from './links-row';
-import { TimeRow } from './time-row';
export const InfoModal = () => {
const modal = useService(DocInfoService).modal;
@@ -119,9 +122,7 @@ export const InfoTable = ({
);
return (
-
-
-
+ <>
{backlinks && backlinks.length > 0 ? (
<>
>
) : null}
-
- isCollapsed
- ? hide === 1
- ? t['com.affine.page-properties.more-property.one']({
- count: hide.toString(),
- })
- : t['com.affine.page-properties.more-property.more']({
- count: hide.toString(),
- })
- : hide === 1
- ? t['com.affine.page-properties.hide-property.one']({
- count: hide.toString(),
- })
- : t['com.affine.page-properties.hide-property.more']({
- count: hide.toString(),
- })
- }
+
- {properties.map(property => (
-
- ))}
- }
- contentOptions={{
- onClick(e) {
- e.stopPropagation();
- },
- }}
+
+ isCollapsed
+ ? hide === 1
+ ? t['com.affine.page-properties.more-property.one']({
+ count: hide.toString(),
+ })
+ : t['com.affine.page-properties.more-property.more']({
+ count: hide.toString(),
+ })
+ : hide === 1
+ ? t['com.affine.page-properties.hide-property.one']({
+ count: hide.toString(),
+ })
+ : t['com.affine.page-properties.hide-property.more']({
+ count: hide.toString(),
+ })
+ }
>
- }
- className={styles.addPropertyButton}
+ {properties.map(property => (
+
+ ))}
+ }
+ contentOptions={{
+ onClick(e) {
+ e.stopPropagation();
+ },
+ }}
>
- {t['com.affine.page-properties.add-property']()}
-
-
-
-
+ }
+ className={styles.addPropertyButton}
+ >
+ {t['com.affine.page-properties.add-property']()}
+
+
+
+
+
+
+ >
);
};
diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts
index 4f093464f629c..026f6cb393dc8 100644
--- a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts
+++ b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.css.ts
@@ -1,13 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
-export const title = style({
- fontSize: cssVar('fontSm'),
- fontWeight: '500',
- color: cssVar('textSecondaryColor'),
- padding: '6px',
-});
-
export const wrapper = style({
width: '100%',
borderRadius: 4,
@@ -15,11 +8,7 @@ export const wrapper = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
- gap: 2,
- padding: '6px',
- ':hover': {
- backgroundColor: cssVar('hoverColor'),
- },
+ padding: 4,
});
globalStyle(`${wrapper} svg`, {
diff --git a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx
index e59a050c3ab93..f6f52f1fb5c73 100644
--- a/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx
+++ b/packages/frontend/core/src/components/doc-properties/info-modal/links-row.tsx
@@ -1,3 +1,4 @@
+import { PropertyCollapsibleSection } from '@affine/component';
import type { Backlink, Link } from '@affine/core/modules/doc-link';
import { AffinePageReference } from '../../affine/reference-link';
@@ -15,10 +16,10 @@ export const LinksRow = ({
onClick?: () => void;
}) => {
return (
-
-
- {label} · {references.length}
-
+
{references.map((link, index) => (
))}
-
+
);
};
diff --git a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx b/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx
index 8315bd157fb49..de235de977d1f 100644
--- a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx
+++ b/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx
@@ -74,7 +74,11 @@ export const CreatePropertyMenuItems = ({
>
{name}
- {isUniqueExist && Added}
+ {isUniqueExist && (
+
+ {t['com.affine.page-properties.create-property.added']()}
+
+ )}
);
diff --git a/packages/frontend/core/src/components/doc-properties/table.css.ts b/packages/frontend/core/src/components/doc-properties/table.css.ts
index 00a2bc948c2bb..5bb035845302c 100644
--- a/packages/frontend/core/src/components/doc-properties/table.css.ts
+++ b/packages/frontend/core/src/components/doc-properties/table.css.ts
@@ -39,36 +39,12 @@ export const rootCentered = style({
export const tableHeader = style({
display: 'flex',
- flexDirection: 'column',
- justifyContent: 'space-between',
-});
-
-export const tableHeaderInfoRow = style({
- display: 'flex',
- flexDirection: 'row',
+ height: 30,
+ padding: 4,
justifyContent: 'space-between',
alignItems: 'center',
color: cssVarV2('text/secondary'),
- fontSize: fontSize,
fontWeight: 500,
- minHeight: 34,
- '@media': {
- print: {
- display: 'none',
- },
- },
-});
-
-export const tableHeaderSecondaryRow = style({
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- color: cssVar('textPrimaryColor'),
- fontSize: fontSize,
- fontWeight: 500,
- padding: `0 6px`,
- gap: '8px',
- height: 24,
'@media': {
print: {
display: 'none',
@@ -81,6 +57,7 @@ export const tableHeaderCollapseButtonWrapper = style({
flex: 1,
justifyContent: 'flex-end',
cursor: 'pointer',
+ fontSize: 20,
});
export const pageInfoDimmed = style({
diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/doc-properties/table.tsx
index 4edc0f26e907e..ac768c7484f57 100644
--- a/packages/frontend/core/src/components/doc-properties/table.tsx
+++ b/packages/frontend/core/src/components/doc-properties/table.tsx
@@ -1,21 +1,19 @@
import {
Button,
- IconButton,
Menu,
MenuItem,
- PropertyCollapsible,
+ PropertyCollapsibleContent,
+ PropertyCollapsibleSection,
PropertyName,
PropertyRoot,
- Tooltip,
useDraggable,
useDropTarget,
} from '@affine/component';
-import { DocLinksService } from '@affine/core/modules/doc-link';
-import { EditorSettingService } from '@affine/core/modules/editor-settting';
+import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { AffineDNDData } from '@affine/core/types/dnd';
-import { i18nTime, useI18n } from '@affine/i18n';
+import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { PlusIcon, PropertyIcon, ToggleExpandIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
@@ -25,14 +23,11 @@ import {
DocsService,
useLiveData,
useService,
- useServices,
- WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
-import { useDebouncedValue } from 'foxact/use-debounced-value';
import type React from 'react';
import type { HTMLProps, PropsWithChildren } from 'react';
-import { forwardRef, useCallback, useMemo, useState } from 'react';
+import { forwardRef, useCallback, useState } from 'react';
import { AffinePageReference } from '../affine/reference-link';
import { DocPropertyIcon } from './icons/doc-property-icon';
@@ -81,145 +76,38 @@ interface DocPropertiesTableHeaderProps {
onOpenChange: (open: boolean) => void;
}
-// backlinks - #no Updated yyyy-mm-dd
+// Info
// ─────────────────────────────────────────────────
-// Page Info ...
export const DocPropertiesTableHeader = ({
className,
style,
open,
onOpenChange,
}: DocPropertiesTableHeaderProps) => {
- const t = useI18n();
- const {
- docLinksService,
- docService,
- workspaceService,
- editorSettingService,
- } = useServices({
- DocLinksService,
- DocService,
- WorkspaceService,
- EditorSettingService,
- });
- const docBacklinks = docLinksService.backlinks;
- const backlinks = useMemo(
- () => docBacklinks.backlinks$.value,
- [docBacklinks]
- );
-
- const displayDocInfo = useLiveData(
- editorSettingService.editorSetting.settings$.selector(s => s.displayDocInfo)
- );
-
- const { syncing, retrying, serverClock } = useLiveData(
- workspaceService.workspace.engine.doc.docState$(docService.doc.id)
- );
-
- const { createDate, updatedDate } = useLiveData(
- docService.doc.meta$.selector(m => ({
- createDate: m.createDate,
- updatedDate: m.updatedDate,
- }))
- );
-
- const timestampElement = useMemo(() => {
- const localizedCreateTime = createDate ? i18nTime(createDate) : null;
-
- const createTimeElement = (
-
- {t['Created']()} {localizedCreateTime}
-
- );
-
- return serverClock ? (
-
-
- {t['Updated']()} {i18nTime(serverClock)}
-
- {createDate && (
-
- {t['Created']()} {i18nTime(createDate)}
-
- )}
- >
- }
- >
-
- {!syncing && !retrying ? (
- <>
- {t['Updated']()}{' '}
- {i18nTime(serverClock, {
- relative: {
- max: [1, 'day'],
- accuracy: 'minute',
- },
- absolute: {
- accuracy: 'day',
- },
- })}
- >
- ) : (
- <>{t['com.affine.syncing']()}>
- )}
-
-
- ) : updatedDate ? (
-
-
- {t['Updated']()} {i18nTime(updatedDate)}
-
-
- ) : (
- createTimeElement
- );
- }, [createDate, updatedDate, retrying, serverClock, syncing, t]);
-
- const dTimestampElement = useDebouncedValue(timestampElement, 500);
-
const handleCollapse = useCallback(() => {
track.doc.inlineDocInfo.$.toggle();
onOpenChange(!open);
}, [onOpenChange, open]);
-
+ const t = useI18n();
return (
-
- {/* TODO(@Peng): add click handler to backlinks */}
-
- {backlinks.length > 0 ? (
-
-
- {t['com.affine.page-properties.backlinks']()} · {backlinks.length}
-
-
- ) : null}
- {dTimestampElement}
+
+
+
+ {t['com.affine.page-properties.page-info']()}
+
+
+
+
+
- {displayDocInfo ? (
-
-
- {t['com.affine.page-properties.page-info']()}
-
-
-
-
-
-
-
-
-
- ) : null}
-
+
);
};
@@ -364,13 +252,14 @@ export const DocPropertiesTableBody = forwardRef<
const [newPropertyId, setNewPropertyId] = useState
(null);
return (
-
-
-
-
+
+
);
});
DocPropertiesTableBody.displayName = 'PagePropertiesTableBody';
@@ -455,8 +343,10 @@ const DocPropertiesTableInner = () => {
className={styles.rootCentered}
>
-
+
+
+
diff --git a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx
index 07e84b5137caf..2517748d592cc 100644
--- a/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx
+++ b/packages/frontend/core/src/components/doc-properties/tags-inline-editor.tsx
@@ -1,26 +1,16 @@
-import type { MenuProps } from '@affine/component';
+import type { TagLike } from '@affine/component/ui/tags';
+import { TagsInlineEditor as TagsInlineEditorComponent } from '@affine/component/ui/tags';
+import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import {
- IconButton,
- Input,
- Menu,
- MenuItem,
- MenuSeparator,
- RowInput,
- Scrollable,
-} from '@affine/component';
-import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
-import type { Tag } from '@affine/core/modules/tag';
-import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
-import { useI18n } from '@affine/i18n';
-import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc';
-import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
-import clsx from 'clsx';
-import { clamp } from 'lodash-es';
-import type { HTMLAttributes, PropsWithChildren } from 'react';
-import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
+ LiveData,
+ useLiveData,
+ useService,
+ WorkspaceService,
+} from '@toeverything/infra';
+import { useCallback, useMemo } from 'react';
-import { TagItem, TempTagItem } from '../page-list';
-import * as styles from './tags-inline-editor.css';
+import { useAsyncCallback } from '../hooks/affine-async-hooks';
+import { useNavigateHelper } from '../hooks/use-navigate-helper';
interface TagsEditorProps {
pageId: string;
@@ -28,444 +18,113 @@ interface TagsEditorProps {
focusedIndex?: number;
}
-interface InlineTagsListProps
- extends Omit, 'onChange'>,
- Omit {
- onRemove?: () => void;
+interface TagsInlineEditorProps extends TagsEditorProps {
+ placeholder?: string;
+ className?: string;
}
-export const InlineTagsList = ({
+export const TagsInlineEditor = ({
pageId,
readonly,
- children,
- focusedIndex,
- onRemove,
-}: PropsWithChildren) => {
- const tagList = useService(TagService).tagList;
- const tags = useLiveData(tagList.tags$);
- const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
-
- return (
-
- {tagIds.map((tagId, idx) => {
- const tag = tags.find(t => t.id === tagId);
- if (!tag) {
- return null;
- }
- const onRemoved = readonly
- ? undefined
- : () => {
- tag.untag(pageId);
- onRemove?.();
- };
- return (
-
- );
- })}
- {children}
-
- );
-};
-
-export const EditTagMenu = ({
- tagId,
- onTagDelete,
- children,
-}: PropsWithChildren<{
- tagId: string;
- onTagDelete: (tagIds: string[]) => void;
-}>) => {
- const t = useI18n();
- const workspaceService = useService(WorkspaceService);
- const tagService = useService(TagService);
- const tagList = tagService.tagList;
- const tag = useLiveData(tagList.tagByTagId$(tagId));
- const tagColor = useLiveData(tag?.color$);
- const tagValue = useLiveData(tag?.value$);
- const navigate = useNavigateHelper();
-
- const menuProps = useMemo(() => {
- const updateTagName = (name: string) => {
- if (name.trim() === '') {
- return;
- }
- tag?.rename(name);
- };
-
- return {
- contentOptions: {
- onClick(e) {
- e.stopPropagation();
- },
- },
- items: (
- <>
- {
- updateTagName(e.currentTarget.value);
- }}
- onKeyDown={e => {
- if (e.key === 'Enter') {
- e.preventDefault();
- updateTagName(e.currentTarget.value);
- }
- e.stopPropagation();
- }}
- placeholder={t['Untitled']()}
- />
-
- }
- type="danger"
- onClick={() => {
- onTagDelete([tag?.id || '']);
- }}
- >
- {t['Delete']()}
-
- }
- onClick={() => {
- navigate.jumpToTag(workspaceService.workspace.id, tag?.id || '');
- }}
- >
- {t['com.affine.page-properties.tags.open-tags-page']()}
-
-
-
-
- {tagService.tagColors.map(([name, color], i) => (
-
- ))}
-
-
-
- >
- ),
- } satisfies Partial;
- }, [
- workspaceService,
- navigate,
- onTagDelete,
- t,
- tag,
- tagColor,
- tagService.tagColors,
- tagValue,
- ]);
-
- return ;
-};
-
-type TagOption = Tag | { readonly create: true; readonly value: string };
-const isCreateNewTag = (
- tagOption: TagOption
-): tagOption is { readonly create: true; readonly value: string } => {
- return 'create' in tagOption;
-};
-
-export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
- const t = useI18n();
+ placeholder,
+ className,
+}: TagsInlineEditorProps) => {
+ const workspace = useService(WorkspaceService);
const tagService = useService(TagService);
- const tagList = tagService.tagList;
- const tags = useLiveData(tagList.tags$);
- const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
- const [inputValue, setInputValue] = useState('');
- const filteredTags = useLiveData(
- inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
- );
- const [open, setOpen] = useState(false);
- const [selectedTagIds, setSelectedTagIds] = useState([]);
- const inputRef = useRef(null);
-
- const exactMatch = filteredTags.find(tag => tag.value$.value === inputValue);
- const showCreateTag = !exactMatch && inputValue.trim();
+ const tagIds = useLiveData(tagService.tagList.tagIdsByPageId$(pageId));
+ const tags = useLiveData(tagService.tagList.tags$);
+ const tagColors = tagService.tagColors;
- // tag option candidates to show in the tag dropdown
- const tagOptions: TagOption[] = useMemo(() => {
- if (showCreateTag) {
- return [{ create: true, value: inputValue } as const, ...filteredTags];
- } else {
- return filteredTags;
- }
- }, [filteredTags, inputValue, showCreateTag]);
-
- const [focusedIndex, setFocusedIndex] = useState(-1);
- const [focusedInlineIndex, setFocusedInlineIndex] = useState(
- tagIds.length
+ const onCreateTag = useCallback(
+ (name: string, color: string) => {
+ const newTag = tagService.tagList.createTag(name, color);
+ return {
+ id: newTag.id,
+ value: newTag.value$.value,
+ color: newTag.color$.value,
+ };
+ },
+ [tagService.tagList]
);
- // -1: no focus
- const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1);
- // inline tags focus index can go beyond the length of tagIds
- // using -1 and tagIds.length to make keyboard navigation easier
- const safeInlineFocusedIndex = clamp(focusedInlineIndex, -1, tagIds.length);
-
- const scrollContainerRef = useRef(null);
-
- const handleCloseModal = useCallback(
- (open: boolean) => {
- setOpen(open);
- setSelectedTagIds([]);
+ const onSelectTag = useCallback(
+ (tagId: string) => {
+ tagService.tagList.tagByTagId$(tagId).value?.tag(pageId);
},
- [setOpen]
+ [pageId, tagService.tagList]
);
- const onTagDelete = useCallback(
- (tagIds: string[]) => {
- setOpen(true);
- setSelectedTagIds(tagIds);
+ const onDeselectTag = useCallback(
+ (tagId: string) => {
+ tagService.tagList.tagByTagId$(tagId).value?.untag(pageId);
},
- [setOpen, setSelectedTagIds]
+ [pageId, tagService.tagList]
);
- const onInputChange = useCallback((value: string) => {
- setInputValue(value);
- }, []);
-
- const onToggleTag = useCallback(
- (id: string) => {
- const tagEntity = tagList.tags$.value.find(o => o.id === id);
- if (!tagEntity) {
- return;
- }
- if (!tagIds.includes(id)) {
- tagEntity.tag(pageId);
- } else {
- tagEntity.untag(pageId);
+ const onTagChange = useCallback(
+ (id: string, property: keyof TagLike, value: string) => {
+ if (property === 'value') {
+ tagService.tagList.tagByTagId$(id).value?.rename(value);
+ } else if (property === 'color') {
+ tagService.tagList.tagByTagId$(id).value?.changeColor(value);
}
},
- [pageId, tagIds, tagList.tags$.value]
+ [tagService.tagList]
);
- const focusInput = useCallback(() => {
- inputRef.current?.focus();
- }, []);
+ const deleteTags = useDeleteTagConfirmModal();
- const [nextColor, rotateNextColor] = useReducer(
- color => {
- const idx = tagService.tagColors.findIndex(c => c[1] === color);
- return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1];
+ const onTagDelete = useAsyncCallback(
+ async (id: string) => {
+ await deleteTags([id]);
},
- tagService.tagColors[
- Math.floor(Math.random() * tagService.tagColors.length)
- ][1]
+ [deleteTags]
);
- const onCreateTag = useCallback(
- (name: string) => {
- rotateNextColor();
- const newTag = tagList.createTag(name.trim(), nextColor);
- return newTag.id;
- },
- [nextColor, tagList]
+ const adaptedTags = useLiveData(
+ useMemo(() => {
+ return LiveData.computed(get => {
+ return tags.map(tag => ({
+ id: tag.id,
+ value: get(tag.value$),
+ color: get(tag.color$),
+ }));
+ });
+ }, [tags])
);
- const onSelectTagOption = useCallback(
- (tagOption: TagOption) => {
- const id = isCreateNewTag(tagOption)
- ? onCreateTag(tagOption.value)
- : tagOption.id;
- onToggleTag(id);
- setInputValue('');
- focusInput();
- setFocusedIndex(-1);
- setFocusedInlineIndex(tagIds.length + 1);
- },
- [onCreateTag, onToggleTag, focusInput, tagIds.length]
- );
- const onEnter = useCallback(() => {
- if (safeFocusedIndex >= 0) {
- onSelectTagOption(tagOptions[safeFocusedIndex]);
- }
- }, [onSelectTagOption, safeFocusedIndex, tagOptions]);
+ const adaptedTagColors = useMemo(() => {
+ return tagColors.map(color => ({
+ id: color[0],
+ value: color[1],
+ name: color[0],
+ }));
+ }, [tagColors]);
- const onInputKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
- const tagToRemove =
- safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length
- ? tagIds.length - 1
- : safeInlineFocusedIndex;
- tags.find(item => item.id === tagIds.at(tagToRemove))?.untag(pageId);
- } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
- e.preventDefault();
- const newFocusedIndex = clamp(
- safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1),
- 0,
- tagOptions.length - 1
- );
- scrollContainerRef.current
- ?.querySelector(
- `.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})`
- )
- ?.scrollIntoView({ block: 'nearest' });
- setFocusedIndex(newFocusedIndex);
- // reset inline focus
- setFocusedInlineIndex(tagIds.length + 1);
- } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
- const newItemToFocus =
- e.key === 'ArrowLeft'
- ? safeInlineFocusedIndex - 1
- : safeInlineFocusedIndex + 1;
+ const navigator = useNavigateHelper();
- e.preventDefault();
- setFocusedInlineIndex(newItemToFocus);
- // reset tag list focus
- setFocusedIndex(-1);
- }
+ const jumpToTag = useCallback(
+ (id: string) => {
+ navigator.jumpToTag(workspace.workspace.id, id);
},
- [
- inputValue,
- tagIds,
- safeFocusedIndex,
- tagOptions,
- safeInlineFocusedIndex,
- tags,
- pageId,
- ]
- );
-
- return (
-
-
-
-
-
-
-
-
- {t['com.affine.page-properties.tags.selector-header-title']()}
-
-
-
- {tagOptions.map((tag, idx) => {
- const commonProps = {
- ...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
- onClick: () => onSelectTagOption(tag),
- onMouseEnter: () => setFocusedIndex(idx),
- ['data-testid']: 'tag-selector-item',
- ['data-focused']: safeFocusedIndex === idx,
- className: styles.tagSelectorItem,
- };
- if (isCreateNewTag(tag)) {
- return (
-
- {t['Create']()}{' '}
-
-
- );
- } else {
- return (
-
- );
- }
- })}
-
-
-
-
-
-
+ [navigator, workspace.workspace.id]
);
-};
-
-interface TagsInlineEditorProps extends TagsEditorProps {
- placeholder?: string;
- className?: string;
-}
-// this tags value renderer right now only renders the legacy tags for now
-export const TagsInlineEditor = ({
- pageId,
- readonly,
- placeholder,
- className,
-}: TagsInlineEditorProps) => {
- const tagList = useService(TagService).tagList;
- const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
- const empty = !tagIds || tagIds.length === 0;
return (
- }
- >
-
- {empty ? placeholder : }
-
-
+
);
};
diff --git a/packages/frontend/core/src/components/doc-properties/types/constant.tsx b/packages/frontend/core/src/components/doc-properties/types/constant.tsx
index 3247ee6d1453b..89833eae7b64f 100644
--- a/packages/frontend/core/src/components/doc-properties/types/constant.tsx
+++ b/packages/frontend/core/src/components/doc-properties/types/constant.tsx
@@ -4,6 +4,7 @@ import {
CreatedEditedIcon,
DateTimeIcon,
FileIcon,
+ HistoryIcon,
NumberIcon,
TagIcon,
TextIcon,
@@ -12,7 +13,7 @@ import {
import { CheckboxValue } from './checkbox';
import { CreatedByValue, UpdatedByValue } from './created-updated-by';
-import { DateValue } from './date';
+import { CreateDateValue, DateValue, UpdatedDateValue } from './date';
import { DocPrimaryModeValue } from './doc-primary-mode';
import { JournalValue } from './journal';
import { NumberValue } from './number';
@@ -65,6 +66,20 @@ export const DocPropertyTypes = {
name: 'com.affine.page-properties.property.updatedBy',
description: 'com.affine.page-properties.property.updatedBy.tooltips',
},
+ updatedAt: {
+ icon: DateTimeIcon,
+ value: UpdatedDateValue,
+ name: 'com.affine.page-properties.property.updatedAt',
+ renameable: false,
+ uniqueId: 'updatedAt',
+ },
+ createdAt: {
+ icon: HistoryIcon,
+ value: CreateDateValue,
+ name: 'com.affine.page-properties.property.createdAt',
+ renameable: false,
+ uniqueId: 'createdAt',
+ },
docPrimaryMode: {
icon: FileIcon,
value: DocPrimaryModeValue,
diff --git a/packages/frontend/core/src/components/doc-properties/types/date.tsx b/packages/frontend/core/src/components/doc-properties/types/date.tsx
index 57e9c8ce3669c..6da2b2726cb27 100644
--- a/packages/frontend/core/src/components/doc-properties/types/date.tsx
+++ b/packages/frontend/core/src/components/doc-properties/types/date.tsx
@@ -1,10 +1,11 @@
-import { DatePicker, Menu, PropertyValue } from '@affine/component';
+import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
+import { DocService, useLiveData, useServices } from '@toeverything/infra';
import * as styles from './date.css';
import type { PropertyValueProps } from './types';
-export const DateValue = ({ value, onChange }: PropertyValueProps) => {
+const useParsedDate = (value: string) => {
const parsedValue =
typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}$/)
? value
@@ -12,8 +13,17 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
const displayValue = parsedValue
? i18nTime(parsedValue, { absolute: { accuracy: 'day' } })
: undefined;
-
const t = useI18n();
+ return {
+ parsedValue,
+ displayValue:
+ displayValue ??
+ t['com.affine.page-properties.property-value-placeholder'](),
+ };
+};
+
+export const DateValue = ({ value, onChange }: PropertyValueProps) => {
+ const { parsedValue, displayValue } = useParsedDate(value);
return (
}>
@@ -21,9 +31,55 @@ export const DateValue = ({ value, onChange }: PropertyValueProps) => {
className={parsedValue ? '' : styles.empty}
isEmpty={!parsedValue}
>
- {displayValue ??
- t['com.affine.page-properties.property-value-placeholder']()}
+ {displayValue}
);
};
+
+const toRelativeDate = (time: string | number) => {
+ return i18nTime(time, {
+ relative: {
+ max: [1, 'day'],
+ },
+ absolute: {
+ accuracy: 'day',
+ },
+ });
+};
+
+const MetaDateValueFactory = ({
+ type,
+}: {
+ type: 'createDate' | 'updatedDate';
+}) =>
+ function ReadonlyDateValue() {
+ const { docService } = useServices({
+ DocService,
+ });
+
+ const docMeta = useLiveData(docService.doc.meta$);
+ const value = docMeta?.[type];
+
+ const relativeDate = value ? toRelativeDate(value) : null;
+ const date = value ? i18nTime(value) : null;
+
+ return (
+
+
+ {relativeDate}
+
+
+ );
+ };
+
+export const CreateDateValue = MetaDateValueFactory({
+ type: 'createDate',
+});
+
+export const UpdatedDateValue = MetaDateValueFactory({
+ type: 'updatedDate',
+});
diff --git a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx b/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx
index 8eae4f1895d5e..d175c40f70c49 100644
--- a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx
+++ b/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx
@@ -48,7 +48,7 @@ export const DocPrimaryModeValue = () => {
[doc, t]
);
return (
-
+
void;
}
diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx
index 91aeda5a5b6a9..ab5a9ddc1dc90 100644
--- a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx
+++ b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx
@@ -1,8 +1,8 @@
import { Menu } from '@affine/component';
-import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
+import { TagItem as TagItemComponent } from '@affine/component/ui/tags';
import type { Tag } from '@affine/core/modules/tag';
import { stopPropagation } from '@affine/core/utils';
-import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
+import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
@@ -27,77 +27,24 @@ interface TagItemProps {
style?: React.CSSProperties;
}
-export const TempTagItem = ({
- value,
- color,
- maxWidth = '100%',
-}: {
- value: string;
- color: string;
- maxWidth?: number | string;
-}) => {
- return (
-
- );
-};
-
-export const TagItem = ({
- tag,
- idx,
- mode,
- focused,
- onRemoved,
- style,
- maxWidth,
-}: TagItemProps) => {
+export const TagItem = ({ tag, ...props }: TagItemProps) => {
const value = useLiveData(tag?.value$);
const color = useLiveData(tag?.color$);
- const handleRemove = useCatchEventCallback(() => {
- onRemoved?.();
- }, [onRemoved]);
+
+ if (!tag || !value || !color) {
+ return null;
+ }
+
return (
-
-
-
-
{value}
- {onRemoved ? (
-
-
-
- ) : null}
-
-
+
);
};
diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx
index d6e98ed2a249e..4b6099f104507 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/all-tag/index.tsx
@@ -1,10 +1,11 @@
+import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
TagListHeader,
VirtualizedTagList,
} from '@affine/core/components/page-list/tags';
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import type { TagMeta } from '@affine/core/components/page-list/types';
-import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
+import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
@@ -39,25 +40,14 @@ const EmptyTagListHeader = () => {
export const AllTag = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
- const [open, setOpen] = useState(false);
- const [selectedTagIds, setSelectedTagIds] = useState([]);
-
const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$);
+ const handleDeleteTags = useDeleteTagConfirmModal();
- const handleCloseModal = useCallback(
- (open: boolean) => {
- setOpen(open);
- setSelectedTagIds([]);
+ const onTagDelete = useAsyncCallback(
+ async (tagIds: string[]) => {
+ await handleDeleteTags(tagIds);
},
- [setOpen]
- );
-
- const onTagDelete = useCallback(
- (tagIds: string[]) => {
- setOpen(true);
- setSelectedTagIds(tagIds);
- },
- [setOpen, setSelectedTagIds]
+ [handleDeleteTags]
);
const t = useI18n();
@@ -82,11 +72,6 @@ export const AllTag = () => {
)}
-
>
);
};
diff --git a/packages/frontend/core/src/modules/doc-info/index.ts b/packages/frontend/core/src/modules/doc-info/index.ts
index 14d8afa3df581..01805077da64d 100644
--- a/packages/frontend/core/src/modules/doc-info/index.ts
+++ b/packages/frontend/core/src/modules/doc-info/index.ts
@@ -1,10 +1,22 @@
-import { type Framework, WorkspaceScope } from '@toeverything/infra';
+import {
+ DocsService,
+ type Framework,
+ WorkspaceScope,
+} from '@toeverything/infra';
+import { DocsSearchService } from '../docs-search';
import { DocInfoModal } from './entities/modal';
+import { DocDatabaseBacklinksService } from './services/doc-database-backlinks';
import { DocInfoService } from './services/doc-info';
+export { DocDatabaseBacklinkInfo } from './views/database-properties/doc-database-backlink-info';
+
export { DocInfoService };
export function configureDocInfoModule(framework: Framework) {
- framework.scope(WorkspaceScope).service(DocInfoService).entity(DocInfoModal);
+ framework
+ .scope(WorkspaceScope)
+ .service(DocInfoService)
+ .service(DocDatabaseBacklinksService, [DocsService, DocsSearchService])
+ .entity(DocInfoModal);
}
diff --git a/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts
new file mode 100644
index 0000000000000..f261a2e196e69
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/services/doc-database-backlinks.ts
@@ -0,0 +1,149 @@
+import {
+ DatabaseBlockDataSource,
+ type DatabaseBlockModel,
+} from '@blocksuite/affine/blocks';
+import type { DocsService } from '@toeverything/infra';
+import { Service } from '@toeverything/infra';
+import { isEqual } from 'lodash-es';
+import { combineLatest, distinctUntilChanged, map, Observable } from 'rxjs';
+
+import type { DocsSearchService } from '../../docs-search';
+import type { DatabaseRow, DatabaseValueCell } from '../types';
+import { signalToLiveData, signalToObservable } from '../utils';
+
+const equalComparator = (a: T, b: T) => {
+ return isEqual(a, b);
+};
+
+export class DocDatabaseBacklinksService extends Service {
+ constructor(
+ private readonly docsService: DocsService,
+ private readonly docsSearchService: DocsSearchService
+ ) {
+ super();
+ }
+
+ private async ensureDocLoaded(docId: string) {
+ const docRef = this.docsService.open(docId);
+ if (!docRef.doc.blockSuiteDoc.ready) {
+ docRef.doc.blockSuiteDoc.load();
+ }
+ docRef.doc.setPriorityLoad(10);
+ await docRef.doc.waitForSyncReady();
+ return docRef;
+ }
+
+ private adaptRowCells(dbModel: DatabaseBlockModel, rowId: string) {
+ const dataSource = new DatabaseBlockDataSource(dbModel);
+
+ const hydratedRows$ = combineLatest([
+ signalToObservable(dataSource.rows$),
+ signalToObservable(dataSource.properties$),
+ ]).pipe(
+ map(([rowIds, propertyIds]) => {
+ const rowExists = rowIds.some(id => id === rowId);
+ if (!rowExists) {
+ return undefined;
+ }
+ return propertyIds
+ .map(id => {
+ return {
+ id,
+ value$: signalToLiveData(
+ dataSource.cellValueGet$(rowId, id)
+ ).distinctUntilChanged(equalComparator),
+ property: {
+ id,
+ type$: signalToLiveData(dataSource.propertyTypeGet$(id)),
+ name$: signalToLiveData(dataSource.propertyNameGet$(id)),
+ data$: signalToLiveData(dataSource.propertyDataGet$(id)),
+ },
+ };
+ })
+ .filter((p: any): p is DatabaseValueCell => !!p);
+ })
+ );
+
+ return [hydratedRows$, dataSource] as const;
+ }
+
+ // for each db doc backlink,
+ private watchDatabaseRow$(backlink: {
+ docId: string;
+ rowId: string;
+ databaseBlockId: string;
+ databaseName: string | undefined;
+ }) {
+ return new Observable(subscriber => {
+ let disposed = false;
+ let unsubscribe = () => {};
+ const docRef = this.docsService.open(backlink.docId);
+ const run = async () => {
+ await this.ensureDocLoaded(backlink.docId);
+ if (disposed) {
+ return;
+ }
+ const maybeDatabaseBlock = docRef.doc.blockSuiteDoc.getBlock(
+ backlink.databaseBlockId
+ );
+ if (maybeDatabaseBlock?.flavour === 'affine:database') {
+ const dbModel = maybeDatabaseBlock.model as DatabaseBlockModel;
+ const [cells$, dataSource] = this.adaptRowCells(
+ dbModel,
+ backlink.rowId
+ );
+ const subscription = cells$.subscribe(cells => {
+ if (cells) {
+ subscriber.next({
+ cells,
+ id: backlink.rowId,
+ doc: docRef.doc,
+ docId: backlink.docId,
+ databaseId: dbModel.id,
+ databaseName: dbModel.title.yText.toString(),
+ dataSource: dataSource,
+ });
+ } else {
+ subscriber.next(undefined);
+ }
+ });
+ unsubscribe = () => subscription.unsubscribe();
+ } else {
+ subscriber.next(undefined);
+ }
+ };
+
+ run().catch(e => {
+ console.error(`failed to get database info:`, e);
+ docRef.release();
+ });
+
+ return () => {
+ docRef.release();
+ disposed = true;
+ unsubscribe();
+ };
+ });
+ }
+
+ // backlinks (docid:blockid:databaseBlockId)
+ // -> related db rows (DatabaseRow[])
+ watchDbBacklinkRows$(docId: string) {
+ return this.docsSearchService.watchDatabasesTo(docId).pipe(
+ distinctUntilChanged(equalComparator),
+ map(rows =>
+ rows.toSorted(
+ (a, b) => a.databaseName?.localeCompare(b.databaseName ?? '') ?? 0
+ )
+ ),
+ map(backlinks =>
+ backlinks.map(backlink => {
+ return {
+ ...backlink,
+ row$: this.watchDatabaseRow$(backlink),
+ };
+ })
+ )
+ );
+ }
+}
diff --git a/packages/frontend/core/src/modules/doc-info/types.ts b/packages/frontend/core/src/modules/doc-info/types.ts
new file mode 100644
index 0000000000000..9fa8b9325a914
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/types.ts
@@ -0,0 +1,35 @@
+import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
+import type { Doc, LiveData } from '@toeverything/infra';
+
+// make database property type to be compatible with DocCustomPropertyInfo
+export type DatabaseProperty> = {
+ id: string;
+ name$: LiveData;
+ type$: LiveData;
+ data$: LiveData;
+};
+
+export interface DatabaseValueCell<
+ T = unknown,
+ Data = Record,
+> {
+ value$: LiveData;
+ property: DatabaseProperty;
+ id: string;
+}
+
+export interface DatabaseRow {
+ cells: DatabaseValueCell[];
+ id: string; // row id (block id)
+ doc: Doc; // the doc that contains the database. required for editing etc.
+ docId: string; // for rendering the doc reference
+ dataSource: DatabaseBlockDataSource;
+ databaseId: string;
+ databaseName: string; // the title
+}
+
+export interface DatabaseCellRendererProps {
+ rowId: string;
+ cell: DatabaseValueCell;
+ dataSource: DatabaseBlockDataSource;
+}
diff --git a/packages/frontend/core/src/modules/doc-info/utils.ts b/packages/frontend/core/src/modules/doc-info/utils.ts
new file mode 100644
index 0000000000000..34e4705b49178
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/utils.ts
@@ -0,0 +1,56 @@
+import { DebugLogger } from '@affine/debug';
+import { BlockStdScope } from '@blocksuite/affine/block-std';
+import { PageEditorBlockSpecs } from '@blocksuite/affine/blocks';
+import type { Doc } from '@blocksuite/affine/store';
+import { LiveData } from '@toeverything/infra';
+import { useMemo } from 'react';
+import { Observable } from 'rxjs';
+
+const logger = new DebugLogger('doc-info');
+
+interface ReadonlySignal {
+ subscribe: (fn: (value: T) => void) => () => void;
+}
+
+export function signalToObservable(
+ signal: ReadonlySignal
+): Observable {
+ return new Observable(subscriber => {
+ const unsub = signal.subscribe(value => {
+ subscriber.next(value);
+ });
+ return () => {
+ unsub();
+ };
+ });
+}
+
+export function signalToLiveData(
+ signal: ReadonlySignal,
+ defaultValue: T
+): LiveData;
+
+export function signalToLiveData(
+ signal: ReadonlySignal
+): LiveData;
+
+export function signalToLiveData(
+ signal: ReadonlySignal,
+ defaultValue?: T
+) {
+ return LiveData.from(signalToObservable(signal), defaultValue);
+}
+
+// todo(pengx17): use rc pool?
+export function createBlockStdScope(doc: Doc) {
+ logger.debug('createBlockStdScope', doc.id);
+ const std = new BlockStdScope({
+ doc,
+ extensions: PageEditorBlockSpecs,
+ });
+ return std;
+}
+
+export function useBlockStdScope(doc: Doc) {
+ return useMemo(() => createBlockStdScope(doc), [doc]);
+}
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx
new file mode 100644
index 0000000000000..2892f4d86ebd6
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx
@@ -0,0 +1,22 @@
+import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox';
+import type { LiveData } from '@toeverything/infra';
+import { useLiveData } from '@toeverything/infra';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+
+export const CheckboxCell = ({
+ cell,
+ rowId,
+ dataSource,
+}: DatabaseCellRendererProps) => {
+ const value = useLiveData(cell.value$ as LiveData);
+ return (
+ {
+ dataSource.cellValueChange(rowId, cell.property.id, v === 'true');
+ }}
+ />
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx
new file mode 100644
index 0000000000000..02b5f65eb13e4
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx
@@ -0,0 +1,40 @@
+import { DateValue } from '@affine/core/components/doc-properties/types/date';
+import type { LiveData } from '@toeverything/infra';
+import { useLiveData } from '@toeverything/infra';
+import dayjs from 'dayjs';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+
+const toInternalDateString = (date: unknown) => {
+ if (typeof date !== 'string' && typeof date !== 'number') {
+ return '';
+ }
+ return dayjs(date).format('YYYY-MM-DD');
+};
+
+const fromInternalDateString = (date: string) => {
+ return dayjs(date).toDate().getTime();
+};
+
+export const DateCell = ({
+ cell,
+ rowId,
+ dataSource,
+}: DatabaseCellRendererProps) => {
+ const value = useLiveData(
+ cell.value$ as LiveData
+ );
+ const date = value ? toInternalDateString(value) : '';
+ return (
+ {
+ dataSource.cellValueChange(
+ rowId,
+ cell.property.id,
+ fromInternalDateString(v)
+ );
+ }}
+ />
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts
new file mode 100644
index 0000000000000..eefa8961d526a
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.css.ts
@@ -0,0 +1,56 @@
+import { cssVar } from '@toeverything/theme';
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const link = style({
+ textDecoration: 'none',
+ color: cssVarV2('text/link'),
+ whiteSpace: 'wrap',
+ wordBreak: 'break-all',
+ display: 'inline',
+});
+
+export const textarea = style({
+ border: 'none',
+ height: '100%',
+ width: '100%',
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ whiteSpace: 'wrap',
+ wordBreak: 'break-all',
+ padding: `6px`,
+ paddingLeft: '5px',
+ overflow: 'hidden',
+ fontSize: cssVar('fontSm'),
+ lineHeight: '22px',
+ selectors: {
+ '&::placeholder': {
+ color: cssVar('placeholderColor'),
+ },
+ },
+});
+
+export const container = style({
+ position: 'relative',
+ outline: `1px solid transparent`,
+ padding: `6px`,
+ display: 'block',
+ ':focus-within': {
+ outline: `1px solid ${cssVar('blue700')}`,
+ boxShadow: cssVar('activeShadow'),
+ backgroundColor: cssVarV2('layer/background/hoverOverlay'),
+ },
+});
+
+export const textInvisible = style({
+ border: 'none',
+ whiteSpace: 'wrap',
+ wordBreak: 'break-all',
+ overflow: 'hidden',
+ visibility: 'hidden',
+ fontSize: cssVar('fontSm'),
+ lineHeight: '22px',
+});
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx
new file mode 100644
index 0000000000000..46cedacb7adee
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/link.tsx
@@ -0,0 +1,145 @@
+import { PropertyValue } from '@affine/component';
+import { AffinePageReference } from '@affine/core/components/affine/reference-link';
+import { resolveLinkToDoc } from '@affine/core/modules/navigation';
+import { useI18n } from '@affine/i18n';
+import type { LiveData } from '@toeverything/infra';
+import { useLiveData } from '@toeverything/infra';
+import {
+ type ChangeEventHandler,
+ type KeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+import * as styles from './link.css';
+
+export const LinkCell = ({
+ cell,
+ dataSource,
+ rowId,
+}: DatabaseCellRendererProps) => {
+ const isEmpty = useLiveData(
+ cell.value$.map(value => typeof value !== 'string' || !value)
+ );
+ const link = useLiveData(cell.value$ as LiveData) || '';
+
+ const [editing, setEditing] = useState(false);
+ const [tempValue, setTempValue] = useState(link);
+
+ const ref = useRef(null);
+ const commitChange = useCallback(() => {
+ dataSource.cellValueChange(rowId, cell.id, tempValue.trim());
+ setEditing(false);
+ setTempValue(tempValue.trim());
+ }, [dataSource, rowId, cell.id, tempValue]);
+
+ const handleOnChange: ChangeEventHandler = useCallback(
+ e => {
+ setTempValue(e.target.value);
+ },
+ []
+ );
+
+ const resolvedDocLink = useMemo(() => {
+ const docInfo = resolveLinkToDoc(link);
+
+ if (docInfo) {
+ const params = new URLSearchParams();
+ if (docInfo.mode) {
+ params.set('mode', docInfo.mode);
+ }
+ if (docInfo.blockIds) {
+ params.set('blockIds', docInfo.blockIds.join(','));
+ }
+ if (docInfo.elementIds) {
+ params.set('elementIds', docInfo.elementIds.join(','));
+ }
+ return {
+ docId: docInfo.docId,
+ params,
+ };
+ }
+ return null;
+ }, [link]);
+
+ const onKeydown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ commitChange();
+ } else if (e.key === 'Escape') {
+ setEditing(false);
+ setTempValue(link);
+ }
+ },
+ [commitChange, link]
+ );
+
+ useEffect(() => {
+ setTempValue(link);
+ }, [link]);
+
+ const onClick = useCallback(() => {
+ setEditing(true);
+ setTimeout(() => {
+ ref.current?.focus();
+ });
+ }, []);
+
+ const onLinkClick = useCallback((e: React.MouseEvent) => {
+ // prevent click event from propagating to parent (editing)
+ e.stopPropagation();
+ setEditing(false);
+ }, []);
+
+ const t = useI18n();
+
+ return (
+
+ {!editing ? (
+ resolvedDocLink ? (
+
+ ) : (
+
+ {link?.replace(/^https?:\/\//, '').trim()}
+
+ )
+ ) : (
+ <>
+
+
+ {tempValue}
+ {tempValue?.endsWith('\n') || !tempValue ?
: null}
+
+ >
+ )}
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx
new file mode 100644
index 0000000000000..7415274bce95c
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx
@@ -0,0 +1,20 @@
+import { NumberValue } from '@affine/core/components/doc-properties/types/number';
+import { useLiveData } from '@toeverything/infra';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+
+export const NumberCell = ({
+ cell,
+ rowId,
+ dataSource,
+}: DatabaseCellRendererProps) => {
+ const value = useLiveData(cell.value$);
+ return (
+ {
+ dataSource.cellValueChange(rowId, cell.property.id, v);
+ }}
+ />
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx
new file mode 100644
index 0000000000000..a0c5a1db128f9
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/progress.tsx
@@ -0,0 +1,34 @@
+import { Progress, PropertyValue } from '@affine/component';
+import type { LiveData } from '@toeverything/infra';
+import { useLiveData } from '@toeverything/infra';
+import { useEffect, useState } from 'react';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+
+export const ProgressCell = ({
+ cell,
+ dataSource,
+ rowId,
+}: DatabaseCellRendererProps) => {
+ const value = useLiveData(cell.value$ as LiveData);
+ const isEmpty = value === undefined;
+ const [localValue, setLocalValue] = useState(value);
+
+ useEffect(() => {
+ setLocalValue(value);
+ }, [value]);
+
+ return (
+
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx
new file mode 100644
index 0000000000000..a1a9da13bec98
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/rich-text.tsx
@@ -0,0 +1,62 @@
+import { PropertyValue } from '@affine/component';
+import type { BlockStdScope } from '@blocksuite/affine/block-std';
+import {
+ DefaultInlineManagerExtension,
+ RichText,
+} from '@blocksuite/affine/blocks';
+import type { Doc } from '@blocksuite/affine/store';
+import { type LiveData, useLiveData } from '@toeverything/infra';
+import { useEffect, useRef } from 'react';
+import type * as Y from 'yjs';
+
+import type { DatabaseCellRendererProps } from '../../../types';
+import { useBlockStdScope } from '../../../utils';
+
+// todo(@pengx17): handle markdown/keyboard shortcuts
+const renderRichText = ({
+ doc,
+ std,
+ text,
+}: {
+ std: BlockStdScope;
+ text: Y.Text;
+ doc: Doc;
+}) => {
+ const inlineManager = std.get(DefaultInlineManagerExtension.identifier);
+
+ if (!inlineManager) {
+ return null;
+ }
+
+ const richText = new RichText();
+ richText.yText = text;
+ richText.undoManager = doc.history;
+ richText.readonly = doc.readonly;
+ richText.attributesSchema = inlineManager.getSchema() as any;
+ richText.attributeRenderer = inlineManager.getRenderer();
+ return richText;
+};
+
+export const RichTextCell = ({
+ cell,
+ dataSource,
+}: DatabaseCellRendererProps) => {
+ const std = useBlockStdScope(dataSource.doc);
+ const text = useLiveData(cell.value$ as LiveData);
+ const ref = useRef(null);
+ // todo(@pengx17): following is a workaround to y.Text that it is got renewed when the cell is updated externally. however it breaks the cursor position.
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.innerHTML = '';
+ const richText = renderRichText({ doc: dataSource.doc, std, text });
+ if (richText) {
+ ref.current.append(richText);
+ return () => {
+ richText.remove();
+ };
+ }
+ }
+ return () => {};
+ }, [dataSource.doc, std, text]);
+ return ;
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts
new file mode 100644
index 0000000000000..11152a2e1265d
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.css.ts
@@ -0,0 +1,11 @@
+import { style } from '@vanilla-extract/css';
+
+export const tagInlineEditor = style({
+ width: '100%',
+ padding: `6px`,
+ minHeight: 34,
+});
+
+export const container = style({
+ padding: '0px !important',
+});
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx
new file mode 100644
index 0000000000000..d670b6c57a421
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx
@@ -0,0 +1,259 @@
+/* eslint-disable rxjs/finnish */
+
+import { PropertyValue } from '@affine/component';
+import { type TagLike, TagsInlineEditor } from '@affine/component/ui/tags';
+import { paletteLineToTag, TagService } from '@affine/core/modules/tag';
+import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
+import type { SelectTag } from '@blocksuite/data-view';
+import { LiveData, useLiveData, useService } from '@toeverything/infra';
+import { nanoid } from 'nanoid';
+import { useCallback, useMemo } from 'react';
+
+import type {
+ DatabaseCellRendererProps,
+ DatabaseValueCell,
+} from '../../../types';
+import * as styles from './select.css';
+
+interface SelectPropertyData {
+ options: SelectTag[];
+}
+
+type SelectCellValue = string[] | string;
+
+type SelectCell = DatabaseValueCell<
+ T,
+ SelectPropertyData
+>;
+
+type SingleSelectCell = SelectCell;
+type MultiSelectCell = SelectCell;
+
+const adapter = {
+ getSelectedIds$(cell: SingleSelectCell | MultiSelectCell) {
+ return cell.value$.map(ids => {
+ if (!Array.isArray(ids)) {
+ return typeof ids === 'string' ? [ids] : [];
+ }
+ return ids.filter(id => typeof id === 'string');
+ });
+ },
+
+ getSelectedTags$(cell: SingleSelectCell | MultiSelectCell) {
+ return LiveData.computed(get => {
+ const ids = get(adapter.getSelectedIds$(cell));
+ const options = get(adapter.getTagOptions$(cell));
+ return ids
+ .map(
+ id =>
+ typeof id === 'string' && options.find(option => option.id === id)
+ )
+ .filter(option => !!option);
+ });
+ },
+
+ getTagOptions$(cell: SingleSelectCell | MultiSelectCell) {
+ return LiveData.computed(get => {
+ const data = get(cell.property.data$);
+ return data?.options as SelectTag[];
+ });
+ },
+
+ updateOptions(
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ updater: (oldOptions: SelectTag[]) => SelectTag[]
+ ) {
+ const oldData = dataSource.propertyDataGet(cell.property.id);
+ return dataSource.propertyDataSet(cell.property.id, {
+ ...oldData,
+ options: updater(oldData.options as SelectTag[]),
+ });
+ },
+
+ deselectTag(
+ rowId: string,
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ tagId: string,
+ multiple: boolean
+ ) {
+ const ids = adapter.getSelectedIds$(cell).value;
+ dataSource.cellValueChange(
+ rowId,
+ cell.property.id,
+ multiple ? ids.filter(id => id !== tagId) : undefined
+ );
+ },
+
+ selectTag(
+ rowId: string,
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ tagId: string,
+ multiple: boolean
+ ) {
+ const ids = adapter.getSelectedIds$(cell).value;
+ dataSource.cellValueChange(
+ rowId,
+ cell.property.id,
+ multiple ? [...ids, tagId] : tagId
+ );
+ },
+
+ createTag(
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ newTag: TagLike
+ ) {
+ adapter.updateOptions(cell, dataSource, options => [
+ ...options,
+ {
+ id: newTag.id,
+ value: newTag.value,
+ color: newTag.color,
+ },
+ ]);
+ },
+
+ deleteTag(
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ tagId: string
+ ) {
+ adapter.updateOptions(cell, dataSource, options =>
+ options.filter(option => option.id !== tagId)
+ );
+ },
+
+ updateTag(
+ cell: SingleSelectCell | MultiSelectCell,
+ dataSource: DatabaseBlockDataSource,
+ tagId: string,
+ updater: (oldTag: SelectTag) => SelectTag
+ ) {
+ adapter.updateOptions(cell, dataSource, options =>
+ options.map(option => (option.id === tagId ? updater(option) : option))
+ );
+ },
+};
+
+const BlocksuiteDatabaseSelector = ({
+ cell,
+ dataSource,
+ rowId,
+ multiple,
+}: DatabaseCellRendererProps & { multiple: boolean }) => {
+ const tagService = useService(TagService);
+ const selectCell = cell as any as SingleSelectCell | MultiSelectCell;
+ const selectedIds = useLiveData(adapter.getSelectedIds$(selectCell));
+ const tagOptions = useLiveData(adapter.getTagOptions$(selectCell));
+
+ const onCreateTag = useCallback(
+ (name: string, color: string) => {
+ // bs database uses --affine-tag-xxx colors
+ const newTag = {
+ id: nanoid(),
+ value: name,
+ color: color,
+ };
+ adapter.createTag(selectCell, dataSource, newTag);
+ return newTag;
+ },
+ [dataSource, selectCell]
+ );
+ const onDeleteTag = useCallback(
+ (tagId: string) => {
+ adapter.deleteTag(selectCell, dataSource, tagId);
+ },
+ [dataSource, selectCell]
+ );
+ const onDeselectTag = useCallback(
+ (tagId: string) => {
+ adapter.deselectTag(rowId, selectCell, dataSource, tagId, multiple);
+ },
+ [selectCell, dataSource, rowId, multiple]
+ );
+
+ const onSelectTag = useCallback(
+ (tagId: string) => {
+ adapter.selectTag(rowId, selectCell, dataSource, tagId, multiple);
+ },
+ [rowId, selectCell, dataSource, multiple]
+ );
+
+ const tagColors = useMemo(() => {
+ return tagService.tagColors.map(([name, color]) => ({
+ id: name,
+ value: paletteLineToTag(color), // map from palette line to tag color
+ name,
+ }));
+ }, [tagService.tagColors]);
+
+ const onTagChange = useCallback(
+ (tagId: string, property: string, value: string) => {
+ adapter.updateTag(selectCell, dataSource, tagId, old => {
+ return {
+ ...old,
+ [property]: value,
+ };
+ });
+ },
+ [dataSource, selectCell]
+ );
+
+ return (
+
+ );
+};
+
+export const SelectCell = ({
+ cell,
+ dataSource,
+ rowId,
+}: DatabaseCellRendererProps) => {
+ const isEmpty = useLiveData(
+ cell.value$.map(value => Array.isArray(value) && value.length === 0)
+ );
+ return (
+
+
+
+ );
+};
+
+export const MultiSelectCell = ({
+ cell,
+ dataSource,
+ rowId,
+}: DatabaseCellRendererProps) => {
+ const isEmpty = useLiveData(
+ cell.value$.map(value => Array.isArray(value) && value.length === 0)
+ );
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/constant.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/constant.tsx
new file mode 100644
index 0000000000000..1804cc6d0fea3
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/constant.tsx
@@ -0,0 +1,74 @@
+import type { I18nString } from '@affine/i18n';
+import {
+ CheckBoxCheckLinearIcon,
+ DateTimeIcon,
+ LinkIcon,
+ MultiSelectIcon,
+ NumberIcon,
+ ProgressIcon,
+ SingleSelectIcon,
+ TextIcon,
+} from '@blocksuite/icons/rc';
+
+import type { DatabaseCellRendererProps } from '../../types';
+import { CheckboxCell } from './cells/checkbox';
+import { DateCell } from './cells/date';
+import { LinkCell } from './cells/link';
+import { NumberCell } from './cells/number';
+import { ProgressCell } from './cells/progress';
+import { RichTextCell } from './cells/rich-text';
+import { MultiSelectCell, SelectCell } from './cells/select';
+
+export const DatabaseRendererTypes = {
+ 'rich-text': {
+ Icon: TextIcon,
+ Renderer: RichTextCell,
+ name: 'com.affine.page-properties.property.text',
+ },
+ checkbox: {
+ Icon: CheckBoxCheckLinearIcon,
+ Renderer: CheckboxCell,
+ name: 'com.affine.page-properties.property.checkbox',
+ },
+ date: {
+ Icon: DateTimeIcon,
+ Renderer: DateCell,
+ name: 'com.affine.page-properties.property.date',
+ },
+ number: {
+ Icon: NumberIcon,
+ Renderer: NumberCell,
+ name: 'com.affine.page-properties.property.number',
+ },
+ link: {
+ Icon: LinkIcon,
+ Renderer: LinkCell,
+ name: 'com.affine.page-properties.property.link',
+ },
+ progress: {
+ Icon: ProgressIcon,
+ Renderer: ProgressCell,
+ name: 'com.affine.page-properties.property.progress',
+ },
+ select: {
+ Icon: SingleSelectIcon,
+ Renderer: SelectCell,
+ name: 'com.affine.page-properties.property.select',
+ },
+ 'multi-select': {
+ Icon: MultiSelectIcon,
+ Renderer: MultiSelectCell,
+ name: 'com.affine.page-properties.property.multi-select',
+ },
+} as Record<
+ string,
+ {
+ Icon: React.FC>;
+ Renderer: React.FC;
+ name: I18nString;
+ }
+>;
+
+export const isSupportedDatabaseRendererType = (type?: string): boolean => {
+ return type ? type in DatabaseRendererTypes : false;
+};
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.css.ts b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.css.ts
new file mode 100644
index 0000000000000..4c77634ad7a03
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.css.ts
@@ -0,0 +1,47 @@
+import { cssVar } from '@toeverything/theme';
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { globalStyle, style } from '@vanilla-extract/css';
+
+export const root = style({
+ display: 'flex',
+ flexDirection: 'column',
+});
+
+export const section = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+});
+
+export const cell = style({
+ display: 'flex',
+ gap: 4,
+});
+
+export const divider = style({
+ margin: '8px 0',
+});
+
+export const spacer = style({
+ flex: 1,
+});
+
+export const docRefLink = style({
+ maxWidth: '50%',
+ fontSize: cssVar('fontSm'),
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: cssVarV2('text/tertiary'),
+});
+
+export const cellList = style({
+ padding: '0 2px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+});
+
+globalStyle(`${docRefLink} .affine-reference-title`, {
+ border: 'none',
+});
diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx
new file mode 100644
index 0000000000000..96371ece3a016
--- /dev/null
+++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/doc-database-backlink-info.tsx
@@ -0,0 +1,167 @@
+import {
+ Divider,
+ PropertyCollapsibleContent,
+ PropertyCollapsibleSection,
+ PropertyName,
+} from '@affine/component';
+import { AffinePageReference } from '@affine/core/components/affine/reference-link';
+import { useI18n } from '@affine/i18n';
+import type { DatabaseBlockDataSource } from '@blocksuite/affine/blocks';
+import { DatabaseTableViewIcon } from '@blocksuite/icons/rc';
+import {
+ DocService,
+ LiveData,
+ useLiveData,
+ useService,
+} from '@toeverything/infra';
+import { Fragment, useMemo } from 'react';
+import type { Observable } from 'rxjs';
+
+import { DocDatabaseBacklinksService } from '../../services/doc-database-backlinks';
+import type { DatabaseRow, DatabaseValueCell } from '../../types';
+import { DatabaseRendererTypes } from './constant';
+import * as styles from './doc-database-backlink-info.css';
+
+type CellConfig =
+ (typeof DatabaseRendererTypes)[keyof typeof DatabaseRendererTypes];
+
+const DatabaseBacklinkCellName = ({
+ cell,
+ config,
+}: {
+ cell: DatabaseValueCell;
+ config: CellConfig;
+}) => {
+ const propertyName = useLiveData(cell.property.name$);
+ const t = useI18n();
+ return (
+ }
+ name={propertyName ?? (config.name ? t.t(config.name) : t['unnamed']())}
+ />
+ );
+};
+
+const DatabaseBacklinkCell = ({
+ cell,
+ dataSource,
+ rowId,
+}: {
+ cell: DatabaseValueCell;
+ dataSource: DatabaseBlockDataSource;
+ rowId: string;
+}) => {
+ const cellType = useLiveData(cell.property.type$);
+
+ const config = cellType ? DatabaseRendererTypes[cellType] : undefined;
+
+ // do not render title cell!
+ if (!config || cellType === 'title') {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+};
+
+/**
+ * A row in the database backlink info.
+ * Note: it is being rendered in a list. The name might be confusing.
+ */
+const DatabaseBacklinkRow = ({
+ defaultOpen = false,
+ row$,
+}: {
+ defaultOpen: boolean;
+ row$: Observable;
+}) => {
+ const row = useLiveData(
+ useMemo(() => LiveData.from(row$, undefined), [row$])
+ );
+ const sortedCells = useMemo(() => {
+ return row?.cells.toSorted((a, b) => {
+ return (a.property.name$.value ?? '').localeCompare(
+ b.property.name$.value ?? ''
+ );
+ });
+ }, [row?.cells]);
+ const t = useI18n();
+
+ if (!row || !sortedCells) {
+ return null;
+ }
+
+ return (
+ }
+ suffix={
+
+ }
+ >
+
+ {sortedCells.map(cell => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export const DocDatabaseBacklinkInfo = ({
+ defaultOpen = [],
+}: {
+ defaultOpen?: {
+ docId: string;
+ blockId: string;
+ }[];
+}) => {
+ const doc = useService(DocService).doc;
+ const docDatabaseBacklinks = useService(DocDatabaseBacklinksService);
+ const rows = useLiveData(
+ useMemo(
+ () =>
+ LiveData.from(docDatabaseBacklinks.watchDbBacklinkRows$(doc.id), []),
+ [docDatabaseBacklinks, doc.id]
+ )
+ );
+
+ if (!rows.length) {
+ return null;
+ }
+
+ return (
+
+ {rows.map(({ docId, rowId, row$ }) => (
+
+ backlink.docId === docId && backlink.blockId === rowId
+ )}
+ row$={row$}
+ />
+
+
+ ))}
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
index b5fc1c3cf0cff..6d03bc166120f 100644
--- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
+++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts
@@ -8,7 +8,8 @@ import {
WorkspaceEngineBeforeStart,
} from '@toeverything/infra';
import { isEmpty, omit } from 'lodash-es';
-import { type Observable, switchMap } from 'rxjs';
+import { map, type Observable, switchMap } from 'rxjs';
+import { z } from 'zod';
import { DocsIndexer } from '../entities/docs-indexer';
@@ -509,6 +510,76 @@ export class DocsSearchService extends Service {
);
}
+ watchDatabasesTo(docId: string) {
+ const DatabaseAdditionalSchema = z.object({
+ databaseName: z.string().optional(),
+ });
+ return this.indexer.blockIndex
+ .search$(
+ {
+ type: 'boolean',
+ occur: 'must',
+ queries: [
+ {
+ type: 'match',
+ field: 'refDocId',
+ match: docId,
+ },
+ {
+ type: 'match',
+ field: 'parentFlavour',
+ match: 'affine:database',
+ },
+ // Ignore if it is a link to the current document.
+ {
+ type: 'boolean',
+ occur: 'must_not',
+ queries: [
+ {
+ type: 'match',
+ field: 'docId',
+ match: docId,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ fields: ['docId', 'blockId', 'parentBlockId', 'additional'],
+ pagination: {
+ limit: 100,
+ },
+ }
+ )
+ .pipe(
+ map(({ nodes }) => {
+ return nodes.map(node => {
+ const additional =
+ typeof node.fields.additional === 'string'
+ ? node.fields.additional
+ : node.fields.additional[0];
+
+ return {
+ docId:
+ typeof node.fields.docId === 'string'
+ ? node.fields.docId
+ : node.fields.docId[0],
+ rowId:
+ typeof node.fields.blockId === 'string'
+ ? node.fields.blockId
+ : node.fields.blockId[0],
+ databaseBlockId:
+ typeof node.fields.parentBlockId === 'string'
+ ? node.fields.parentBlockId
+ : node.fields.parentBlockId[0],
+ databaseName: DatabaseAdditionalSchema.safeParse(additional).data
+ ?.databaseName as string | undefined,
+ };
+ });
+ })
+ );
+ }
+
async getDocTitle(docId: string) {
const doc = await this.indexer.docIndex.get(docId);
const title = doc?.get('title');
diff --git a/packages/frontend/core/src/modules/tag/entities/tag.ts b/packages/frontend/core/src/modules/tag/entities/tag.ts
index f963fa59b7197..a198d65255f25 100644
--- a/packages/frontend/core/src/modules/tag/entities/tag.ts
+++ b/packages/frontend/core/src/modules/tag/entities/tag.ts
@@ -2,7 +2,7 @@ import type { DocsService } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import type { TagStore } from '../stores/tag';
-import { tagColorMap } from './utils';
+import { tagToPaletteLine } from './utils';
export class Tag extends Entity<{ id: string }> {
id = this.props.id;
@@ -20,7 +20,7 @@ export class Tag extends Entity<{ id: string }> {
value$ = this.tagOption$.map(tag => tag?.value || '');
- color$ = this.tagOption$.map(tag => tagColorMap(tag?.color ?? '') || '');
+ color$ = this.tagOption$.map(tag => tagToPaletteLine(tag?.color ?? '') || '');
createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now());
diff --git a/packages/frontend/core/src/modules/tag/entities/utils.ts b/packages/frontend/core/src/modules/tag/entities/utils.ts
index c6fe88e25b8c7..f61582a8d5e52 100644
--- a/packages/frontend/core/src/modules/tag/entities/utils.ts
+++ b/packages/frontend/core/src/modules/tag/entities/utils.ts
@@ -1,16 +1,25 @@
+const tagToPaletteLineMap: Record = {
+ 'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
+ 'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
+ 'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
+ 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
+ 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
+ 'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
+ 'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
+ 'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
+ 'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
+ 'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
+};
+
+const paletteLineToTagMap: Record = Object.fromEntries(
+ Object.entries(tagToPaletteLineMap).map(([key, value]) => [value, key])
+);
+
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
-export const tagColorMap = (color: string) => {
- const mapping: Record = {
- 'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
- 'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
- 'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
- 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
- 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
- 'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
- 'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
- 'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
- 'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
- 'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
- };
- return mapping[color] || color;
+export const tagToPaletteLine = (color: string) => {
+ return tagToPaletteLineMap[color] || color;
+};
+
+export const paletteLineToTag = (color: string) => {
+ return paletteLineToTagMap[color] || color;
};
diff --git a/packages/frontend/core/src/modules/tag/index.ts b/packages/frontend/core/src/modules/tag/index.ts
index 69179f7aa0987..56e4a73bbca73 100644
--- a/packages/frontend/core/src/modules/tag/index.ts
+++ b/packages/frontend/core/src/modules/tag/index.ts
@@ -1,7 +1,7 @@
export { Tag } from './entities/tag';
-export { tagColorMap } from './entities/utils';
+export { paletteLineToTag, tagToPaletteLine } from './entities/utils';
export { TagService } from './service/tag';
-export { DeleteTagConfirmModal } from './view/delete-tag-modal';
+export { useDeleteTagConfirmModal } from './view/delete-tag-modal';
import {
DocsService,
diff --git a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx
index c607a30c8b487..80898523565dd 100644
--- a/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx
+++ b/packages/frontend/core/src/modules/tag/view/delete-tag-modal.tsx
@@ -1,64 +1,77 @@
-import { ConfirmModal, toast } from '@affine/component';
+import { toast, useConfirmModal } from '@affine/component';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
import { TagService } from '../service/tag';
-export const DeleteTagConfirmModal = ({
- open,
- onOpenChange,
- selectedTagIds,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedTagIds: string[];
-}) => {
+/**
+ * Show a confirm modal AND delete the tags
+ */
+export const useDeleteTagConfirmModal = () => {
+ const { openConfirmModal } = useConfirmModal();
+
const t = useI18n();
const tagService = useService(TagService);
const tags = useLiveData(tagService.tagList.tags$);
- const selectedTags = useMemo(() => {
- return tags.filter(tag => selectedTagIds.includes(tag.id));
- }, [selectedTagIds, tags]);
- const tagName = useLiveData(selectedTags[0]?.value$ || '');
-
- const handleDelete = useCallback(() => {
- selectedTagIds.forEach(tagId => {
- tagService.tagList.deleteTag(tagId);
- });
- toast(
- selectedTagIds.length > 1
- ? t['com.affine.delete-tags.count']({ count: selectedTagIds.length })
- : t['com.affine.tags.delete-tags.toast']()
- );
+ const confirm = useCallback(
+ (tagIdsToDelete: string[]) => {
+ let closed = false;
+ const { resolve, promise } = Promise.withResolvers();
+ const tagsToDelete = tags.filter(tag => tagIdsToDelete.includes(tag.id));
+ const tagName = tagsToDelete[0]?.value$.value;
+ const handleClose = (state: boolean) => {
+ if (!closed) {
+ closed = true;
+ resolve(state);
- onOpenChange(false);
- }, [onOpenChange, selectedTagIds, t, tagService]);
-
- return (
- }}
- />
- ) : (
- t['com.affine.delete-tags.confirm.multi-tag-description']({
- count: selectedTags.length.toString(),
- })
- )
- }
- confirmText={t['Delete']()}
- confirmButtonOptions={{
- variant: 'error',
- }}
- onConfirm={handleDelete}
- />
+ if (state) {
+ tagIdsToDelete.forEach(tagId => {
+ tagService.tagList.deleteTag(tagId);
+ });
+ toast(
+ tagIdsToDelete.length > 1
+ ? t['com.affine.delete-tags.count']({
+ count: tagIdsToDelete.length,
+ })
+ : t['com.affine.tags.delete-tags.toast']()
+ );
+ }
+ }
+ };
+ openConfirmModal({
+ title: t['com.affine.delete-tags.confirm.title'](),
+ description:
+ tagIdsToDelete.length === 1 ? (
+ }}
+ />
+ ) : (
+ t['com.affine.delete-tags.confirm.multi-tag-description']({
+ count: tagIdsToDelete.length.toString(),
+ })
+ ),
+ confirmText: t['Delete'](),
+ confirmButtonOptions: {
+ variant: 'error',
+ },
+ onConfirm: () => {
+ handleClose(true);
+ },
+ onCancel: () => {
+ handleClose(true);
+ },
+ onOpenChange: state => {
+ handleClose(state);
+ },
+ });
+ return promise;
+ },
+ [openConfirmModal, t, tagService.tagList, tags]
);
+
+ return confirm;
};
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index 22f5bdf084ade..7d370ccc228ca 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -2,7 +2,7 @@
"ar": 85,
"ca": 6,
"da": 6,
- "de": 32,
+ "de": 31,
"en": 100,
"es-AR": 15,
"es-CL": 17,
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index dd3907c315b9b..55cfbdebb2979 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -134,7 +134,7 @@
"com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.",
- "com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You’ve got the latest version of AFFiNE.",
+ "com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You've got the latest version of AFFiNE.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart to apply update.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})",
"com.affine.aboutAFFiNE.checkUpdate.title": "Check for updates",
@@ -156,7 +156,7 @@
"com.affine.ai-onboarding.edgeless.title": "Right-clicking to select content AI",
"com.affine.ai-onboarding.general.1.description": "Lets you think bigger, create faster, work smarter and save time for every project.",
"com.affine.ai-onboarding.general.1.title": "Meet AFFiNE AI",
- "com.affine.ai-onboarding.general.2.description": "Answer questions, draft docs, visualize ideas - AFFiNE AI can save you time at every possible step. Powered by GPT’s most powerful model.",
+ "com.affine.ai-onboarding.general.2.description": "Answer questions, draft docs, visualize ideas - AFFiNE AI can save you time at every possible step. Powered by GPT's most powerful model.",
"com.affine.ai-onboarding.general.2.title": "Chat with AFFiNE AI",
"com.affine.ai-onboarding.general.3.description": "Get insightful answer to any question, instantly.",
"com.affine.ai-onboarding.general.3.title": "Edit inline with AFFiNE AI",
@@ -213,7 +213,7 @@
"com.affine.appearanceSettings.title": "Appearance settings",
"com.affine.appearanceSettings.translucentUI.description": "Use transparency effect on the sidebar.",
"com.affine.appearanceSettings.translucentUI.title": "Translucent UI on the sidebar",
- "com.affine.auth.change.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.",
+ "com.affine.auth.change.email.message": "Your current email is {{email}}. We'll send a temporary verification link to this email.",
"com.affine.auth.change.email.page.subtitle": "Please enter your new email address below. We will send a verification link to this email address to complete the process.",
"com.affine.auth.change.email.page.success.subtitle": "Congratulations! You have successfully updated the email address associated with your AFFiNE Cloud account.",
"com.affine.auth.change.email.page.success.title": "Email address updated!",
@@ -277,14 +277,14 @@
"com.affine.auth.sign.up": "Sign up",
"com.affine.auth.sign.up.sent.email.subtitle": "Create your account",
"com.affine.auth.sign.up.success.subtitle": "The app will automatically open or redirect to the web version. If you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
- "com.affine.auth.sign.up.success.title": "Your account has been created and you’re now signed in!",
+ "com.affine.auth.sign.up.success.title": "Your account has been created and you're now signed in!",
"com.affine.auth.signed.success.subtitle": "You have successfully signed in. The app will automatically open or redirect to the web version. if you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
- "com.affine.auth.signed.success.title": "You’re almost there!",
+ "com.affine.auth.signed.success.title": "You're almost there!",
"com.affine.auth.toast.message.failed": "Server error, please try again later.",
"com.affine.auth.toast.message.signed-in": "You have been signed in, start to sync your data with AFFiNE Cloud!",
"com.affine.auth.toast.title.failed": "Unable to sign in",
"com.affine.auth.toast.title.signed-in": "Signed in",
- "com.affine.auth.verify.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.",
+ "com.affine.auth.verify.email.message": "Your current email is {{email}}. We'll send a temporary verification link to this email.",
"com.affine.backButton": "Back",
"com.affine.banner.content": "This demo is limited. <1>Download the AFFiNE Client1> for the latest features and Performance.",
"com.affine.banner.local-warning": "Your local data is stored in the browser and may be lost. Don't risk it - enable cloud now!",
@@ -628,6 +628,7 @@
"com.affine.page-properties.config-properties": "Config properties",
"com.affine.page-properties.backlinks": "Backlinks",
"com.affine.page-properties.create-property.menu.header": "Type",
+ "com.affine.page-properties.create-property.added": "Added",
"com.affine.page-properties.icons": "Icons",
"com.affine.page-properties.local-user": "Local user",
"com.affine.page-properties.outgoing-links": "Outgoing links",
@@ -656,6 +657,8 @@
"com.affine.page-properties.property.journal-duplicated": "Duplicated",
"com.affine.page-properties.property.journal-remove": "Remove journal mark",
"com.affine.page-properties.property.updatedBy": "Last edited by",
+ "com.affine.page-properties.property.createdAt": "Created at",
+ "com.affine.page-properties.property.updatedAt": "Updated at",
"com.affine.page-properties.property.tags.tooltips": "Add relevant identifiers or categories to the doc. Useful for organizing content, improving searchability, and grouping related docs together.",
"com.affine.page-properties.property.journal.tooltips": "Indicates that this doc is a journal entry or daily note. Facilitates easy capture of ideas, quick logging of thoughts, and ongoing personal reflection.",
"com.affine.page-properties.property.checkbox.tooltips": "Use a checkbox to indicate whether a condition is true or false. Useful for confirming options, toggling features, or tracking task states.",
@@ -1077,7 +1080,7 @@
"com.affine.settings.editorSettings.general.default-new-doc.title": "New doc default mode",
"com.affine.settings.editorSettings.general.font-family.custom.description": "Customize your text experience.",
"com.affine.settings.editorSettings.general.font-family.custom.title": "Custom font family",
- "com.affine.settings.editorSettings.general.font-family.description": "Choose your editor’s font family.",
+ "com.affine.settings.editorSettings.general.font-family.description": "Choose your editor's font family.",
"com.affine.settings.editorSettings.general.font-family.title": "Font family",
"com.affine.settings.editorSettings.general.spell-check.description": "Automatically detect and correct spelling errors.",
"com.affine.settings.editorSettings.general.spell-check.title": "Spell check",
@@ -1307,5 +1310,6 @@
"recommendBrowser": " We recommend the <1>Chrome1> browser for optimal experience.",
"system": "System",
"unnamed": "unnamed",
- "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience."
+ "upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
+ "com.affine.workspace.properties": "Workspace properties"
}
diff --git a/tests/affine-local/e2e/blocksuite/editor.spec.ts b/tests/affine-local/e2e/blocksuite/editor.spec.ts
index 40b4e0568f2ae..e408212008a76 100644
--- a/tests/affine-local/e2e/blocksuite/editor.spec.ts
+++ b/tests/affine-local/e2e/blocksuite/editor.spec.ts
@@ -1,22 +1,13 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import {
+ addDatabase,
clickNewPageButton,
getBlockSuiteEditorTitle,
waitForEditorLoad,
} from '@affine-test/kit/utils/page-logic';
-import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
-const addDatabase = async (page: Page) => {
- await page.keyboard.press('/', { delay: 500 });
- await page.keyboard.press('d', { delay: 500 });
- await page.keyboard.press('a', { delay: 500 });
- await page.keyboard.press('t', { delay: 500 });
- await page.keyboard.press('a', { delay: 500 });
- await page.getByTestId('Table View').click();
-};
-
test('database is useable', async ({ page }) => {
test.slow();
await openHomePage(page);
diff --git a/tests/affine-local/e2e/page-properties.spec.ts b/tests/affine-local/e2e/page-properties.spec.ts
index c93273f96e04e..11e9dadf875ef 100644
--- a/tests/affine-local/e2e/page-properties.spec.ts
+++ b/tests/affine-local/e2e/page-properties.spec.ts
@@ -5,7 +5,10 @@ import {
openJournalsPage,
} from '@affine-test/kit/utils/load-page';
import {
+ addDatabase,
+ addDatabaseRow,
clickNewPageButton,
+ createLinkedPage,
dragTo,
waitForEditorLoad,
waitForEmptyEditor,
@@ -118,11 +121,13 @@ test('property table reordering', async ({ page }) => {
'bottom'
);
- // new order should be Doc mode, (Tags), Number, Date, Checkbox, Text
+ // new order should be Doc mode, (Tags), Created at, Updated at, Number, Date, Checkbox, Text
for (const [index, property] of [
'Tags',
'Doc mode',
'Journal',
+ 'Created at',
+ 'Updated at',
'Number',
'Date',
'Checkbox',
@@ -163,6 +168,8 @@ test('page info show more will show all properties', async ({ page }) => {
'Tags',
'Doc mode',
'Journal',
+ 'Created at',
+ 'Updated at',
'Text',
'Number',
'Date',
@@ -261,3 +268,89 @@ test('delete property via property popup', async ({ page }) => {
page.locator('[data-testid="http://localhost:8080/"]:has-text("Text")')
).not.toBeVisible();
});
+
+test('workspace properties can be collapsed', async ({ page }) => {
+ await expect(page.getByTestId('doc-property-row').first()).toBeVisible();
+ await page.getByRole('button', { name: 'Workspace properties' }).click();
+ await expect(page.getByTestId('doc-property-row').first()).not.toBeVisible();
+ await page.getByRole('button', { name: 'Workspace properties' }).click();
+ await expect(page.getByTestId('doc-property-row').first()).toBeVisible();
+});
+
+// todo: add more tests for database backlink info for different cell types
+test('can show database backlink info', async ({ page }) => {
+ const pageTitle = 'some page title';
+ await clickNewPageButton(page, pageTitle);
+ await page.keyboard.press('Enter');
+
+ const databaseTitle = 'some database title';
+ await addDatabase(page, databaseTitle);
+
+ await expect(page.locator('affine-database-title')).toContainText(
+ databaseTitle
+ );
+
+ await expect(
+ page.locator(`affine-database-title:has-text("${databaseTitle}")`)
+ ).toBeVisible();
+
+ await addDatabaseRow(page, databaseTitle);
+
+ // the new row's title cell should have been focused at the point of adding the row
+ await createLinkedPage(page, 'linked page');
+
+ // change status label
+ await page.keyboard.press('Escape');
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('Done');
+ await page
+ .locator('affine-multi-tag-select .select-option:has-text("Done")')
+ .click();
+
+ // go back to title cell
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('Enter');
+
+ // goto the linked page
+ await page.locator('.affine-reference-title:has-text("linked page")').click();
+
+ // ensure the page properties are visible
+ await ensurePagePropertiesVisible(page);
+
+ // database backlink property should be rendered, but collapsed
+ const linkedDatabaseSection = page
+ .getByTestId('property-collapsible-section')
+ .filter({
+ hasText: 'some database title',
+ });
+ await expect(linkedDatabaseSection).toBeVisible();
+
+ await expect(
+ linkedDatabaseSection.getByTestId('property-collapsible-section-content')
+ ).not.toBeVisible();
+
+ await expect(
+ linkedDatabaseSection.locator(
+ `.affine-reference-title:has-text("${pageTitle}")`
+ )
+ ).toBeVisible();
+
+ // expand the linked database section
+ await linkedDatabaseSection
+ .getByTestId('property-collapsible-section-trigger')
+ .click();
+
+ await expect(
+ linkedDatabaseSection.getByTestId('property-collapsible-section-content')
+ ).toBeVisible();
+
+ await expect(
+ linkedDatabaseSection
+ .getByTestId('database-backlink-cell')
+ .getByTestId('inline-tags-list')
+ .filter({
+ hasText: 'Done',
+ })
+ ).toBeVisible();
+});
diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts
index 6111f9d778bc7..bc29276e70df2 100644
--- a/tests/kit/utils/page-logic.ts
+++ b/tests/kit/utils/page-logic.ts
@@ -134,3 +134,27 @@ export const focusInlineEditor = async (page: Page) => {
.locator('.inline-editor')
.focus();
};
+
+export const addDatabase = async (page: Page, title?: string) => {
+ await page.keyboard.press('/');
+ await expect(page.locator('affine-slash-menu .slash-menu')).toBeVisible();
+ await page.keyboard.type('database');
+ await page.getByTestId('Table View').click();
+
+ if (title) {
+ await page.locator('affine-database-title').click();
+ await page
+ .locator('affine-database-title rich-text [contenteditable]')
+ .fill(title);
+ await page
+ .locator('affine-database-title rich-text [contenteditable]')
+ .blur();
+ }
+};
+
+export const addDatabaseRow = async (page: Page, databaseTitle: string) => {
+ const db = page.locator(`affine-database-table`, {
+ has: page.locator(`affine-database-title:has-text("${databaseTitle}")`),
+ });
+ await db.locator('.data-view-table-group-add-row-button').click();
+};
diff --git a/yarn.lock b/yarn.lock
index d5ef307e3e13c..cf1963004df93 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -323,9 +323,11 @@ __metadata:
"@emotion/react": "npm:^11.11.4"
"@emotion/styled": "npm:^11.11.5"
"@radix-ui/react-avatar": "npm:^1.0.4"
+ "@radix-ui/react-collapsible": "npm:^1.1.1"
"@radix-ui/react-dialog": "npm:^1.1.1"
"@radix-ui/react-dropdown-menu": "npm:^2.1.1"
"@radix-ui/react-popover": "npm:^1.0.7"
+ "@radix-ui/react-progress": "npm:^1.1.0"
"@radix-ui/react-radio-group": "npm:^1.1.3"
"@radix-ui/react-scroll-area": "npm:^1.0.5"
"@radix-ui/react-slider": "npm:^1.2.0"
@@ -9705,7 +9707,7 @@ __metadata:
languageName: node
linkType: hard
-"@radix-ui/react-collapsible@npm:1.1.0, @radix-ui/react-collapsible@npm:^1.0.3, @radix-ui/react-collapsible@npm:^1.1.0":
+"@radix-ui/react-collapsible@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-collapsible@npm:1.1.0"
dependencies:
@@ -9731,6 +9733,32 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-collapsible@npm:^1.0.3, @radix-ui/react-collapsible@npm:^1.1.0, @radix-ui/react-collapsible@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-collapsible@npm:1.1.1"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.0"
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-context": "npm:1.1.1"
+ "@radix-ui/react-id": "npm:1.1.0"
+ "@radix-ui/react-presence": "npm:1.1.1"
+ "@radix-ui/react-primitive": "npm:2.0.0"
+ "@radix-ui/react-use-controllable-state": "npm:1.1.0"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/44332b493b5f7dfb044f08bcc287ce221625434bf40b25f0181c536af562a138f4ccdc14ce46a2e1771b3c7633cedca3d41646cbb8a9dc7c8d263f26f58874d1
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-collection@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-collection@npm:1.1.0"
@@ -9833,6 +9861,19 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-context@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-context@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/f6469583bf11cc7bff3ea5c95c56b0774a959512adead00dc64b0527cca01b90b476ca39a64edfd7e18e428e17940aa0339116b1ce5b6e8eab513cfd1065d391
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-dialog@npm:1.0.5":
version: 1.0.5
resolution: "@radix-ui/react-dialog@npm:1.0.5"
@@ -10369,6 +10410,26 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-presence@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-presence@npm:1.1.1"
+ dependencies:
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/1ae074efae47ab52a63239a5936fddb334b2f66ed91e74bfe8b1ae591e5db01fa7e9ddb1412002cc043066d40478ba05187a27eb2684dcd68dea545993f9ee20
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-primitive@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-primitive@npm:1.0.3"